From 718a3eab491068a32c2b8d2eb2794699330a8336 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 21 Oct 2025 18:08:56 +0200 Subject: [PATCH 01/19] feat(core): Truncate request messages in AI integrations (#17921) Fixes: https://github.com/getsentry/sentry-javascript/issues/17809 Implements message truncation logic that drops oldest messages first until the payload fits within the 20KB limit. If a single message exceeds the limit, its content is truncated from the end. Supports OpenAI/Anthropic ({ role, content }) and Google GenAI ({ role, parts: [{ text }] }) message formats. --- .../anthropic/scenario-message-truncation.mjs | 71 +++++ .../suites/tracing/anthropic/test.ts | 36 +++ .../scenario-message-truncation.mjs | 69 ++++ .../suites/tracing/google-genai/test.ts | 38 +++ .../openai/scenario-message-truncation.mjs | 69 ++++ .../suites/tracing/openai/test.ts | 40 ++- .../core/src/utils/ai/messageTruncation.ts | 296 ++++++++++++++++++ packages/core/src/utils/ai/utils.ts | 21 ++ packages/core/src/utils/anthropic-ai/index.ts | 34 +- packages/core/src/utils/anthropic-ai/utils.ts | 22 +- packages/core/src/utils/google-genai/index.ts | 15 +- packages/core/src/utils/openai/index.ts | 7 +- packages/core/src/utils/vercel-ai/index.ts | 4 +- 13 files changed, 691 insertions(+), 31 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs create mode 100644 packages/core/src/utils/ai/messageTruncation.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs new file mode 100644 index 000000000000..21821cdc5aae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -0,0 +1,71 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.baseURL = config.baseURL; + + // Create messages object with create method + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'msg-truncation-test', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response to truncated messages', + }, + ], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: largeContent1 }, + { role: 'assistant', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index c05db16fc251..57e788b721d1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -497,4 +497,40 @@ describe('Anthropic integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_ERROR_SPANS }).start().completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + // Messages should be present (truncation happened) and should be a JSON array + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..bb24b6835db2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + response: { + text: () => 'Response to truncated messages', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 15, + totalTokenCount: 25, + }, + candidates: [ + { + content: { + parts: [{ text: 'Response to truncated messages' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentGoogleGenAIClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { role: 'user', parts: [{ text: largeContent1 }] }, + { role: 'model', parts: [{ text: largeContent2 }] }, + { role: 'user', parts: [{ text: largeContent3 }] }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 92d669c7e10f..921f94e78765 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -486,4 +486,42 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + // Messages should be present (truncation happened) and should be a JSON array with parts + 'gen_ai.request.messages': expect.stringMatching( + /^\[\{"role":"user","parts":\[\{"text":"C+"\}\]\}\]$/, + ), + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..5623d3763657 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'chatcmpl-truncation-test', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Response to truncated messages', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + }, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: largeContent1 }, + { role: 'user', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); 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 c0c0b79e95f7..8c788834f126 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -187,7 +187,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', - 'gen_ai.request.messages': '"Translate this to French: Hello"', + 'gen_ai.request.messages': 'Translate this to French: Hello', 'gen_ai.response.text': 'Response to: Translate this to French: Hello', 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -261,7 +261,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, - 'gen_ai.request.messages': '"Test streaming responses API"', + 'gen_ai.request.messages': 'Test streaming responses API', 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', 'gen_ai.response.finish_reasons': '["in_progress","completed"]', 'gen_ai.response.id': 'resp_stream_456', @@ -397,4 +397,40 @@ describe('OpenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + // Messages should be present (truncation happened) and should be a JSON array of a single index + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts new file mode 100644 index 000000000000..64d186f927b8 --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,296 @@ +/** + * Default maximum size in bytes for GenAI messages. + * Messages exceeding this limit will be truncated. + */ +export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; + +/** + * Message format used by OpenAI and Anthropic APIs. + */ +type ContentMessage = { + [key: string]: unknown; + content: string; +}; + +/** + * Message format used by Google GenAI API. + * Parts can be strings or objects with a text property. + */ +type PartsMessage = { + [key: string]: unknown; + parts: Array; +}; + +/** + * A part in a Google GenAI message that contains text. + */ +type TextPart = string | { text: string }; + +/** + * Calculate the UTF-8 byte length of a string. + */ +const utf8Bytes = (text: string): number => { + return new TextEncoder().encode(text).length; +}; + +/** + * Calculate the UTF-8 byte length of a value's JSON representation. + */ +const jsonBytes = (value: unknown): number => { + return utf8Bytes(JSON.stringify(value)); +}; + +/** + * Truncate a string to fit within maxBytes when encoded as UTF-8. + * Uses binary search for efficiency with multi-byte characters. + * + * @param text - The string to truncate + * @param maxBytes - Maximum byte length (UTF-8 encoded) + * @returns Truncated string that fits within maxBytes + */ +function truncateTextByBytes(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) { + return text; + } + + let low = 0; + let high = text.length; + let bestFit = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = text.slice(0, mid); + const byteSize = utf8Bytes(candidate); + + if (byteSize <= maxBytes) { + bestFit = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return bestFit; +} + +/** + * Extract text content from a Google GenAI message part. + * Parts are either plain strings or objects with a text property. + * + * @returns The text content + */ +function getPartText(part: TextPart): string { + if (typeof part === 'string') { + return part; + } + return part.text; +} + +/** + * Create a new part with updated text content while preserving the original structure. + * + * @param part - Original part (string or object) + * @param text - New text content + * @returns New part with updated text + */ +function withPartText(part: TextPart, text: string): TextPart { + if (typeof part === 'string') { + return text; + } + return { ...part, text }; +} + +/** + * Check if a message has the OpenAI/Anthropic content format. + */ +function isContentMessage(message: unknown): message is ContentMessage { + return ( + message !== null && + typeof message === 'object' && + 'content' in message && + typeof (message as ContentMessage).content === 'string' + ); +} + +/** + * Check if a message has the Google GenAI parts format. + */ +function isPartsMessage(message: unknown): message is PartsMessage { + return ( + message !== null && + typeof message === 'object' && + 'parts' in message && + Array.isArray((message as PartsMessage).parts) && + (message as PartsMessage).parts.length > 0 + ); +} + +/** + * Truncate a message with `content: string` format (OpenAI/Anthropic). + * + * @param message - Message with content property + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncateContentMessage(message: ContentMessage, maxBytes: number): unknown[] { + // Calculate overhead (message structure without content) + const emptyMessage = { ...message, content: '' }; + const overhead = jsonBytes(emptyMessage); + const availableForContent = maxBytes - overhead; + + if (availableForContent <= 0) { + return []; + } + + const truncatedContent = truncateTextByBytes(message.content, availableForContent); + return [{ ...message, content: truncatedContent }]; +} + +/** + * Truncate a message with `parts: [...]` format (Google GenAI). + * Keeps as many complete parts as possible, only truncating the first part if needed. + * + * @param message - Message with parts array + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { + const { parts } = message; + + // Calculate overhead by creating empty text parts + const emptyParts = parts.map(part => withPartText(part, '')); + const overhead = jsonBytes({ ...message, parts: emptyParts }); + let remainingBytes = maxBytes - overhead; + + if (remainingBytes <= 0) { + return []; + } + + // Include parts until we run out of space + const includedParts: TextPart[] = []; + + for (const part of parts) { + const text = getPartText(part); + const textSize = utf8Bytes(text); + + if (textSize <= remainingBytes) { + // Part fits: include it as-is + includedParts.push(part); + remainingBytes -= textSize; + } else if (includedParts.length === 0) { + // First part doesn't fit: truncate it + const truncated = truncateTextByBytes(text, remainingBytes); + if (truncated) { + includedParts.push(withPartText(part, truncated)); + } + break; + } else { + // Subsequent part doesn't fit: stop here + break; + } + } + + return includedParts.length > 0 ? [{ ...message, parts: includedParts }] : []; +} + +/** + * Truncate a single message to fit within maxBytes. + * + * Supports two message formats: + * - OpenAI/Anthropic: `{ ..., content: string }` + * - Google GenAI: `{ ..., parts: Array }` + * + * @param message - The message to truncate + * @param maxBytes - Maximum byte limit for the message + * @returns Array containing the truncated message, or empty array if truncation fails + */ +function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { + if (!message || typeof message !== 'object') { + return []; + } + + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } + + if (isPartsMessage(message)) { + return truncatePartsMessage(message, maxBytes); + } + + // Unknown message format: cannot truncate safely + return []; +} + +/** + * Truncate an array of messages to fit within a byte limit. + * + * Strategy: + * - Keeps the newest messages (from the end of the array) + * - Uses O(n) algorithm: precompute sizes once, then find largest suffix under budget + * - If no complete messages fit, attempts to truncate the newest single message + * + * @param messages - Array of messages to truncate + * @param maxBytes - Maximum total byte limit for all messages + * @returns Truncated array of messages + * + * @example + * ```ts + * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 + * const truncated = truncateMessagesByBytes(messages, 10000); + * // Returns [msg3, msg4] if they fit, or [msg4] if only it fits, etc. + * ``` + */ +export function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + // Early return for empty or invalid input + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + // Fast path: if all messages fit, return as-is + const totalBytes = jsonBytes(messages); + if (totalBytes <= maxBytes) { + return messages; + } + + // Precompute each message's JSON size once for efficiency + const messageSizes = messages.map(jsonBytes); + + // Find the largest suffix (newest messages) that fits within the budget + let bytesUsed = 0; + let startIndex = messages.length; // Index where the kept suffix starts + + for (let i = messages.length - 1; i >= 0; i--) { + const messageSize = messageSizes[i]; + + if (messageSize && bytesUsed + messageSize > maxBytes) { + // Adding this message would exceed the budget + break; + } + + if (messageSize) { + bytesUsed += messageSize; + } + startIndex = i; + } + + // If no complete messages fit, try truncating just the newest message + if (startIndex === messages.length) { + const newestMessage = messages[messages.length - 1]; + return truncateSingleMessage(newestMessage, maxBytes); + } + + // Return the suffix that fits + return messages.slice(startIndex); +} + +/** + * Truncate GenAI messages using the default byte limit. + * + * Convenience wrapper around `truncateMessagesByBytes` with the default limit. + * + * @param messages - Array of messages to truncate + * @returns Truncated array of messages + */ +export function truncateGenAiMessages(messages: unknown[]): unknown[] { + return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index ecb46d5f0d0d..00e147a16e5f 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -7,6 +7,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from './gen-ai-attributes'; +import { truncateGenAiMessages } from './messageTruncation'; /** * Maps AI method paths to Sentry operation name */ @@ -84,3 +85,23 @@ export function setTokenUsageAttributes( }); } } + +/** + * Get the truncated JSON string for a string or array of strings. + * + * @param value - The string or array of strings to truncate + * @returns The truncated JSON string + */ +export function getTruncatedJsonString(value: T | T[]): string { + if (typeof value === 'string') { + // Some values are already JSON strings, so we don't need to duplicate the JSON parsing + return value; + } + if (Array.isArray(value)) { + // truncateGenAiMessages returns an array of strings, so we need to stringify it + const truncatedMessages = truncateGenAiMessages(value); + return JSON.stringify(truncatedMessages); + } + // value is an object, so we need to stringify it + return JSON.stringify(value); +} diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8e77dd76b34e..d81741668be9 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,7 +23,13 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { + buildMethodPath, + getFinalOperationName, + getSpanOperation, + getTruncatedJsonString, + setTokenUsageAttributes, +} from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -33,7 +39,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { shouldInstrument } from './utils'; +import { handleResponseError, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -77,33 +83,19 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } + if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); } } -/** - * Capture error information from the response - * @see https://docs.anthropic.com/en/api/errors#error-shapes - */ -function handleResponseError(span: Span, response: AnthropicAiResponse): void { - if (response.error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); - - captureException(response.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); - } -} - /** * Add content attributes when recordOutputs is enabled */ diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts index 299d20170d6c..bce96aa68bcc 100644 --- a/packages/core/src/utils/anthropic-ai/utils.ts +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -1,5 +1,8 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod } from './types'; +import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; /** * Check if a method path should be instrumented @@ -7,3 +10,20 @@ import type { AnthropicAiInstrumentedMethod } from './types'; export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } + +/** + * Capture error information from the response + * @see https://docs.anthropic.com/en/api/errors#error-shapes + */ +export function handleResponseError(span: Span, response: AnthropicAiResponse): void { + if (response.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); + + captureException(response.error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.anthropic_error', + }, + }); + } +} diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 20e6e2a53606..9639b1255d29 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,7 +22,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -136,17 +136,24 @@ function extractRequestAttributes( function addPrivateRequestAttributes(span: Span, params: Record): void { // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] if ('contents' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.contents) }); + const contents = params.contents; + // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] + const truncatedContents = getTruncatedJsonString(contents); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedContents }); } // For chat.sendMessage: message can be string or Part[] if ('message' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + const message = params.message; + const truncatedMessage = getTruncatedJsonString(message); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessage }); } // For chats.create: history contains the conversation history if ('history' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + const history = params.history; + const truncatedHistory = getTruncatedJsonString(history); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedHistory }); } } diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 4ecfad625062..bb099199772c 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -19,6 +19,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -191,10 +192,12 @@ 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) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 8f353e88d394..747a3c105449 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -6,6 +6,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -196,7 +197,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); + span.setAttribute('gen_ai.prompt', truncatedPrompt); } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); From cefcdbc9d1780b2acd85b8263a7f5da9a4183eb8 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Oct 2025 13:28:18 -0400 Subject: [PATCH 02/19] chore: Upgrade madge to v8 (#17957) No breaking changes that affect us: https://github.com/pahen/madge/blob/master/CHANGELOG.md#v800 Should reduce our total dependency count. --- package.json | 2 +- yarn.lock | 316 ++++++++++++++++----------------------------------- 2 files changed, 102 insertions(+), 216 deletions(-) diff --git a/package.json b/package.json index 0c3d47a3c7b3..1fd6eb062564 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "eslint": "7.32.0", "jsdom": "^21.1.2", "lerna": "7.1.1", - "madge": "7.0.0", + "madge": "8.0.0", "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", diff --git a/yarn.lock b/yarn.lock index c0bc7ba27923..4a6077f16b4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2873,14 +2873,6 @@ "@deno/shim-deno-test" "^0.5.0" which "^4.0.0" -"@dependents/detective-less@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-4.1.0.tgz#4a979ee7a6a79eb33602862d6a1263e30f98002e" - integrity sha512-KrkT6qO5NxqNfy68sBl6CTSoJ4SNDIS5iQArkibhlbGU4LaDukZ3q2HIkh8aUKDio6o4itU4xDR7t82Y2eP1Bg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - "@dependents/detective-less@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-5.0.1.tgz#e6c5b502f0d26a81da4170c1ccd848a6eaa68470" @@ -8082,6 +8074,33 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@ts-graphviz/adapter@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@ts-graphviz/adapter/-/adapter-2.0.6.tgz#18d5a42304dca7ffff760fcaf311a3148ef4a3bd" + integrity sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/ast@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/ast/-/ast-2.0.7.tgz#4ec33492e4b4e998d4632030e97a9f7e149afb86" + integrity sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/common@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@ts-graphviz/common/-/common-2.1.5.tgz#a256dfaea009a5b147d8f73f25e57fb44f6462a2" + integrity sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg== + +"@ts-graphviz/core@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/core/-/core-2.0.7.tgz#2185e390990038b267a2341c3db1cef3680bbee8" + integrity sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg== + dependencies: + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -9126,11 +9145,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.0.tgz#d725da8dfcff320aab2ac6f65c97b0df30058449" integrity sha512-UTe67B0Ypius0fnEE518NB2N8gGutIlTojeTg4nt0GQvikReVkurqxd2LvYa9q9M5MQ6rtpNyWTBxdscw40Xhw== -"@typescript-eslint/types@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" - integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== - "@typescript-eslint/types@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.4.tgz#5d358484d2be986980c039de68e9f1eb62ea7897" @@ -9167,19 +9181,6 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/typescript-estree@^5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" - integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== - dependencies: - "@typescript-eslint/types" "5.62.0" - "@typescript-eslint/visitor-keys" "5.62.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@^8.23.0": version "8.35.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz#86141e6c55b75bc1eaecc0781bd39704de14e52a" @@ -9231,14 +9232,6 @@ "@typescript-eslint/types" "5.48.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" - integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== - dependencies: - "@typescript-eslint/types" "5.62.0" - eslint-visitor-keys "^3.3.0" - "@typescript-eslint/visitor-keys@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz#80dfecf820fc67574012375859085f91a4dff043" @@ -10853,11 +10846,6 @@ ast-kit@^1.0.1, ast-kit@^1.1.0: "@babel/parser" "^7.25.6" pathe "^1.1.2" -ast-module-types@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-5.0.0.tgz#32b2b05c56067ff38e95df66f11d6afd6c9ba16b" - integrity sha512-JvqziE0Wc0rXQfma0HZC/aY7URXHFuZV84fJRtP8u+lhp0JYCNd5wJzVXP45t0PH0Mej3ynlzvdyITYIu0G4LQ== - ast-module-types@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-6.0.1.tgz#4b4ca0251c57b815bab62604dcb22f8c903e2523" @@ -14184,15 +14172,15 @@ dependency-graph@^0.11.0: resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== -dependency-tree@^10.0.9: - version "10.0.9" - resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-10.0.9.tgz#0c6c0dbeb0c5ec2cf83bf755f30e9cb12e7b4ac7" - integrity sha512-dwc59FRIsht+HfnTVM0BCjJaEWxdq2YAvEDy4/Hn6CwS3CBWMtFnL3aZGAkQn3XCYxk/YcTDE4jX2Q7bFTwCjA== +dependency-tree@^11.0.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-11.2.0.tgz#ae764155b2903267181def4b20be49b1fd76da5e" + integrity sha512-+C1H3mXhcvMCeu5i2Jpg9dc0N29TWTuT6vJD7mHLAfVmAbo9zW8NlkvQ1tYd3PDMab0IRQM0ccoyX68EZtx9xw== dependencies: - commander "^10.0.1" - filing-cabinet "^4.1.6" - precinct "^11.0.5" - typescript "^5.0.4" + commander "^12.1.0" + filing-cabinet "^5.0.3" + precinct "^12.2.0" + typescript "^5.8.3" deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" @@ -14249,16 +14237,6 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detective-amd@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-5.0.2.tgz#579900f301c160efe037a6377ec7e937434b2793" - integrity sha512-XFd/VEQ76HSpym80zxM68ieB77unNuoMwopU2TFT/ErUk5n4KvUTwW4beafAVUugrjV48l4BmmR0rh2MglBaiA== - dependencies: - ast-module-types "^5.0.0" - escodegen "^2.0.0" - get-amd-module-type "^5.0.1" - node-source-walk "^6.0.1" - detective-amd@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-6.0.1.tgz#71eb13b5d9b17222d7b4de3fb89a8e684d8b9a23" @@ -14269,14 +14247,6 @@ detective-amd@^6.0.1: get-amd-module-type "^6.0.1" node-source-walk "^7.0.1" -detective-cjs@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-5.0.1.tgz#836ad51c6de4863efc7c419ec243694f760ff8b2" - integrity sha512-6nTvAZtpomyz/2pmEmGX1sXNjaqgMplhQkskq2MLrar0ZAIkHMrDhLXkRiK2mvbu9wSWr0V5/IfiTrZqAQMrmQ== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.0" - detective-cjs@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-6.0.1.tgz#4fb81a67337630811409abb2148b2b622cacbdcd" @@ -14285,13 +14255,6 @@ detective-cjs@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -detective-es6@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-4.0.1.tgz#38d5d49a6d966e992ef8f2d9bffcfe861a58a88a" - integrity sha512-k3Z5tB4LQ8UVHkuMrFOlvb3GgFWdJ9NqAa2YLUU/jTaWJIm+JJnEh4PsMc+6dfT223Y8ACKOaC0qcj7diIhBKw== - dependencies: - node-source-walk "^6.0.1" - detective-es6@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-5.0.1.tgz#f0c026bc9b767a243e57ef282f4343fcf3b8ec4e" @@ -14299,15 +14262,6 @@ detective-es6@^5.0.1: dependencies: node-source-walk "^7.0.1" -detective-postcss@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-6.1.3.tgz#51a2d4419327ad85d0af071c7054c79fafca7e73" - integrity sha512-7BRVvE5pPEvk2ukUWNQ+H2XOq43xENWbH0LcdCE14mwgTBEAMoAx+Fc1rdp76SmyZ4Sp48HlV7VedUnP6GA1Tw== - dependencies: - is-url "^1.2.4" - postcss "^8.4.23" - postcss-values-parser "^6.0.2" - detective-postcss@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-7.0.1.tgz#f5822d8988339fb56851fcdb079d51fbcff114db" @@ -14316,14 +14270,6 @@ detective-postcss@^7.0.1: is-url "^1.2.4" postcss-values-parser "^6.0.2" -detective-sass@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-5.0.3.tgz#63e54bc9b32f4bdbd9d5002308f9592a3d3a508f" - integrity sha512-YsYT2WuA8YIafp2RVF5CEfGhhyIVdPzlwQgxSjK+TUm3JoHP+Tcorbk3SfG0cNZ7D7+cYWa0ZBcvOaR0O8+LlA== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-sass@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-6.0.1.tgz#fcf5aa51bebf7b721807be418418470ee2409f8a" @@ -14332,14 +14278,6 @@ detective-sass@^6.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-scss@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-4.0.3.tgz#79758baa0158f72bfc4481eb7e21cc3b5f1ea6eb" - integrity sha512-VYI6cHcD0fLokwqqPFFtDQhhSnlFWvU614J42eY6G0s8c+MBhi9QAWycLwIOGxlmD8I/XvGSOUV1kIDhJ70ZPg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" @@ -14348,26 +14286,11 @@ detective-scss@^5.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-stylus@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-4.0.0.tgz#ce97b6499becdc291de7b3c11df8c352c1eee46e" - integrity sha512-TfPotjhszKLgFBzBhTOxNHDsutIxx9GTWjrL5Wh7Qx/ydxKhwUrlSFeLIn+ZaHPF+h0siVBkAQSuy6CADyTxgQ== - detective-stylus@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-5.0.1.tgz#57d54a0b405305ee16655e42008b38a827a9f179" integrity sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA== -detective-typescript@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-11.2.0.tgz#5b1450b518cb84b6cfb98ea72d5edd9660668e1b" - integrity sha512-ARFxjzizOhPqs1fYC/2NMC3N4jrQ6HvVflnXBTRqNEqJuXwyKLRr9CrJwkRcV/SnZt1sNXgsF6FPm0x57Tq0rw== - dependencies: - "@typescript-eslint/typescript-estree" "^5.62.0" - ast-module-types "^5.0.0" - node-source-walk "^6.0.2" - typescript "^5.4.4" - detective-typescript@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-14.0.0.tgz#3cf429652eb7d7d2be2c050ac47af957a559527d" @@ -15324,10 +15247,10 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.0: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -17007,23 +16930,22 @@ filesize@^10.0.5: resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w== -filing-cabinet@^4.1.6: - version "4.2.0" - resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-4.2.0.tgz#bd81241edce6e0c051882bef7b69ffa4c017baf9" - integrity sha512-YZ21ryzRcyqxpyKggdYSoXx//d3sCJzM3lsYoaeg/FyXdADGJrUl+BW1KIglaVLJN5BBcMtWylkygY8zBp2MrQ== +filing-cabinet@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-5.0.3.tgz#e5ab960958653ee7fe70d5d99b3b88c342ce7907" + integrity sha512-PlPcMwVWg60NQkhvfoxZs4wEHjhlOO/y7OAm4sKM60o1Z9nttRY4mcdQxp/iZ+kg/Vv6Hw1OAaTbYVM9DA9pYg== dependencies: app-module-path "^2.2.0" - commander "^10.0.1" - enhanced-resolve "^5.14.1" - is-relative-path "^1.0.2" - module-definition "^5.0.1" - module-lookup-amd "^8.0.5" - resolve "^1.22.3" - resolve-dependency-path "^3.0.2" - sass-lookup "^5.0.1" - stylus-lookup "^5.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" + module-definition "^6.0.1" + module-lookup-amd "^9.0.3" + resolve "^1.22.10" + resolve-dependency-path "^4.0.1" + sass-lookup "^6.1.0" + stylus-lookup "^6.1.0" tsconfig-paths "^4.2.0" - typescript "^5.0.4" + typescript "^5.7.3" fill-range@^4.0.0: version "4.0.0" @@ -17610,14 +17532,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-amd-module-type@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-5.0.1.tgz#bef38ea3674e1aa1bda9c59c8b0da598582f73f2" - integrity sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - get-amd-module-type@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz#191f479ae8706c246b52bf402fbe1bb0965d9f1e" @@ -19689,11 +19603,6 @@ is-regexp@^1.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= -is-relative-path@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-relative-path/-/is-relative-path-1.0.2.tgz#091b46a0d67c1ed0fe85f1f8cfdde006bb251d46" - integrity sha1-CRtGoNZ8HtD+hfH4z93gBrslHUY= - is-set@^2.0.2, is-set@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" @@ -21250,23 +21159,22 @@ lz-string@^1.4.4, lz-string@^1.5.0: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== -madge@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/madge/-/madge-7.0.0.tgz#64b1762033b0f969caa7e5853004b6850e8430bb" - integrity sha512-x9eHkBWoCJ2B8yGesWf8LRucarkbH5P3lazqgvmxe4xn5U2Meyfu906iG9mBB1RnY/f4D+gtELWdiz1k6+jAZA== +madge@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/madge/-/madge-8.0.0.tgz#cca4ab66fb388e7b6bf43c1f78dcaab3cad30f50" + integrity sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw== dependencies: chalk "^4.1.2" commander "^7.2.0" commondir "^1.0.1" debug "^4.3.4" - dependency-tree "^10.0.9" + dependency-tree "^11.0.0" ora "^5.4.1" pluralize "^8.0.0" - precinct "^11.0.5" pretty-ms "^7.0.1" rc "^1.2.8" stream-to-array "^2.3.0" - ts-graphviz "^1.8.1" + ts-graphviz "^2.1.2" walkdir "^0.4.1" magic-regexp@^0.8.0: @@ -22477,14 +22385,6 @@ modify-values@^1.0.1: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -module-definition@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-5.0.1.tgz#62d1194e5d5ea6176b7dc7730f818f466aefa32f" - integrity sha512-kvw3B4G19IXk+BOXnYq/D/VeO9qfHaapMeuS7w7sNUqmGaA6hywdFHMi+VWeR9wUScXM7XjoryTffCZ5B0/8IA== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - module-definition@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-6.0.1.tgz#47e73144cc5a9aa31f3380166fddf8e962ccb2e4" @@ -22498,14 +22398,14 @@ module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== -module-lookup-amd@^8.0.5: - version "8.0.5" - resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-8.0.5.tgz#aaeea41979105b49339380ca3f7d573db78c32a5" - integrity sha512-vc3rYLjDo5Frjox8NZpiyLXsNWJ5BWshztc/5KSOMzpg9k5cHH652YsJ7VKKmtM4SvaxuE9RkrYGhiSjH3Ehow== +module-lookup-amd@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-9.0.5.tgz#2563ba8e4f9dbcda914eac3ba4dc3ad8af80eb7d" + integrity sha512-Rs5FVpVcBYRHPLuhHOjgbRhosaQYLtEo3JIeDIbmNo7mSssi1CTzwMh8v36gAzpbzLGXI9wB/yHh+5+3fY1QVw== dependencies: - commander "^10.0.1" + commander "^12.1.0" glob "^7.2.3" - requirejs "^2.3.6" + requirejs "^2.3.7" requirejs-config-file "^4.0.0" moment@~2.30.1: @@ -23192,13 +23092,6 @@ node-schedule@^2.1.1: long-timeout "0.1.1" sorted-array-functions "^1.3.0" -node-source-walk@^6.0.0, node-source-walk@^6.0.1, node-source-walk@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-6.0.2.tgz#ba81bc4bc0f6f05559b084bea10be84c3f87f211" - integrity sha512-jn9vOIK/nfqoFCcpK89/VCVaLg1IHE6UVfDOzvqmANaJ/rWCTEdH8RZ1V278nv2jr36BJdyQXIAavBLXpzdlag== - dependencies: - "@babel/parser" "^7.21.8" - node-source-walk@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-7.0.1.tgz#3e4ab8d065377228fd038af7b2d4fb58f61defd3" @@ -25491,7 +25384,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -25550,25 +25443,7 @@ prebuild-install@^7.1.1: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -precinct@^11.0.5: - version "11.0.5" - resolved "https://registry.yarnpkg.com/precinct/-/precinct-11.0.5.tgz#3e15b3486670806f18addb54b8533e23596399ff" - integrity sha512-oHSWLC8cL/0znFhvln26D14KfCQFFn4KOLSw6hmLhd+LQ2SKt9Ljm89but76Pc7flM9Ty1TnXyrA2u16MfRV3w== - dependencies: - "@dependents/detective-less" "^4.1.0" - commander "^10.0.1" - detective-amd "^5.0.2" - detective-cjs "^5.0.1" - detective-es6 "^4.0.1" - detective-postcss "^6.1.3" - detective-sass "^5.0.3" - detective-scss "^4.0.3" - detective-stylus "^4.0.0" - detective-typescript "^11.1.0" - module-definition "^5.0.1" - node-source-walk "^6.0.2" - -precinct@^12.0.0: +precinct@^12.0.0, precinct@^12.2.0: version "12.2.0" resolved "https://registry.yarnpkg.com/precinct/-/precinct-12.2.0.tgz#6ab18f48034cc534f2c8fedb318f19a11bcd171b" integrity sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w== @@ -26728,7 +26603,7 @@ requirejs-config-file@^4.0.0: esprima "^4.0.0" stringify-object "^3.2.1" -requirejs@^2.3.6: +requirejs@^2.3.7: version "2.3.7" resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.7.tgz#0b22032e51a967900e0ae9f32762c23a87036bd0" integrity sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw== @@ -26755,10 +26630,10 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" -resolve-dependency-path@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-3.0.2.tgz#012816717bcbe8b846835da11af9d2beb5acef50" - integrity sha512-Tz7zfjhLfsvR39ADOSk9us4421J/1ztVBo4rWUkF38hgHK5m0OCZ3NxFVpqHRkjctnwVa15igEUHFJp8MCS7vA== +resolve-dependency-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz#1b9d43e5b62384301e26d040b9fce61ee5db60bd" + integrity sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ== resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" @@ -26861,7 +26736,7 @@ resolve@1.22.8: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.3, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -27310,12 +27185,13 @@ sass-loader@13.0.2: klona "^2.0.4" neo-async "^2.6.2" -sass-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-5.0.1.tgz#1f01d7ff21e09d8c9dcf8d05b3fca28f2f96e6ed" - integrity sha512-t0X5PaizPc2H4+rCwszAqHZRtr4bugo4pgiCvrBFvIX0XFxnr29g77LJcpyj9A0DcKf7gXMLcgvRjsonYI6x4g== +sass-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-6.1.0.tgz#a13b1f31dd44d2b4bcd55ba8f72763db4d95bd7c" + integrity sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA== dependencies: - commander "^10.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" sass@1.54.4: version "1.54.4" @@ -28777,12 +28653,12 @@ stylus-loader@7.0.0: klona "^2.0.5" normalize-path "^3.0.0" -stylus-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-5.0.1.tgz#3c4d116c3b1e8e1a8169c0d9cd20e608595560f4" - integrity sha512-tLtJEd5AGvnVy4f9UHQMw4bkJJtaAcmo54N+ovQBjDY3DuWyK9Eltxzr5+KG0q4ew6v2EHyuWWNnHeiw/Eo7rQ== +stylus-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-6.1.0.tgz#f0fe88a885b830dc7520f51dd0a7e59e5d3307b4" + integrity sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ== dependencies: - commander "^10.0.1" + commander "^12.1.0" stylus@0.59.0, stylus@^0.59.0: version "0.59.0" @@ -29574,10 +29450,15 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -ts-graphviz@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-1.8.2.tgz#6c4768d05f8a36e37abe34855ffe89a4c4bd96cc" - integrity sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA== +ts-graphviz@^2.1.2: + version "2.1.6" + resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-2.1.6.tgz#007fcb42b4e8c55d26543ece9e86395bd3c3cfd6" + integrity sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw== + dependencies: + "@ts-graphviz/adapter" "^2.0.6" + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@ts-graphviz/core" "^2.0.7" ts-interface-checker@^0.1.9: version "0.1.13" @@ -29811,10 +29692,10 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +"typescript@>=3 < 6", typescript@^5.7.3, typescript@^5.8.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typescript@^3.9: version "3.9.10" @@ -29826,6 +29707,11 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== +typescript@~5.8.0: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" From 7333e18f5e8abc856a04d37f24ec6019c93f6853 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 22 Oct 2025 15:11:27 +0200 Subject: [PATCH 03/19] feat(core): Instrument LangChain AI (#17955) This PR adds automatic instrumentation for LangChain chat clients in Node SDK, we cover most used providers mentioned in https://python.langchain.com/docs/integrations/chat/. **What's added?** TLDR; a [LangChain Callback Handler ](https://js.langchain.com/docs/concepts/callbacks/) that: - Creates a stateful callback handler that tracks LangChain lifecycle events - Handles LLM/Chat Model events (start, end, error, streaming) - Handles Chain events (start, end, error) - Handles Tool events (start, end, error) - Extracts and normalizes request/respo **How it works?** 1. **Module Patching**: When a LangChain provider package is loaded (e.g., `@langchain/anthropic`), the instrumentation: - Finds the chat model class (e.g., `ChatAnthropic`) - Wraps the `invoke`, `stream`, and `batch` methods on the prototype - Uses a Proxy to intercept method calls 2. **Callback Injection**: When a LangChain method is called: - The wrapper intercepts the call - Augments the `options.callbacks` array with Sentry's callback handler - Calls the original method with the augmented callbacks The integration is **enabled by default** when you initialize Sentry in Node.js: ```javascript import * as Sentry from '@sentry/node'; import { ChatAnthropic } from '@langchain/anthropic'; Sentry.init({ dsn: 'your-dsn', tracesSampleRate: 1.0, sendDefaultPii: true, // Enable to record inputs/outputs }); // LangChain calls are automatically instrumented const model = new ChatAnthropic({ model: 'claude-3-5-sonnet-20241022', }); await model.invoke('What is the capital of France?'); ``` You can configure what data is recorded: ```javascript Sentry.init({ integrations: [ Sentry.langChainIntegration({ recordInputs: true, // Record prompts/messages recordOutputs: true, // Record responses }) ], }); ``` Note: We need to disable integrations for AI providers that LangChain use to avoid duplicate spans, this will be handled in a follow up PR. --- .size-limit.js | 2 +- .../node-integration-tests/package.json | 2 + .../tracing/langchain/instrument-with-pii.mjs | 19 + .../suites/tracing/langchain/instrument.mjs | 19 + .../tracing/langchain/scenario-tools.mjs | 90 ++++ .../suites/tracing/langchain/scenario.mjs | 110 +++++ .../suites/tracing/langchain/test.ts | 197 ++++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/core/src/index.ts | 3 + .../core/src/utils/ai/gen-ai-attributes.ts | 15 + .../core/src/utils/langchain/constants.ts | 11 + packages/core/src/utils/langchain/index.ts | 321 +++++++++++++ packages/core/src/utils/langchain/types.ts | 208 +++++++++ packages/core/src/utils/langchain/utils.ts | 424 ++++++++++++++++++ packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + .../node/src/integrations/tracing/index.ts | 3 + .../integrations/tracing/langchain/index.ts | 107 +++++ .../tracing/langchain/instrumentation.ts | 214 +++++++++ yarn.lock | 114 ++++- 22 files changed, 1851 insertions(+), 18 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/test.ts create mode 100644 packages/core/src/utils/langchain/constants.ts create mode 100644 packages/core/src/utils/langchain/index.ts create mode 100644 packages/core/src/utils/langchain/types.ts create mode 100644 packages/core/src/utils/langchain/utils.ts create mode 100644 packages/node/src/integrations/tracing/langchain/index.ts create mode 100644 packages/node/src/integrations/tracing/langchain/instrumentation.ts diff --git a/.size-limit.js b/.size-limit.js index 9cebd30285e4..269ce49b1cc1 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '156 KB', + limit: '157 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5f2ab2023405..b4fd1c3b4125 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -27,6 +27,8 @@ "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", + "@langchain/anthropic": "^0.3.10", + "@langchain/core": "^0.3.28", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs new file mode 100644 index 000000000000..85b2a963d977 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs new file mode 100644 index 000000000000..524d19f4b995 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs new file mode 100644 index 000000000000..256ee4568884 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs @@ -0,0 +1,90 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + // Simulate tool call response + res.json({ + id: 'msg_tool_test_123', + type: 'message', + role: 'assistant', + model: model, + content: [ + { + type: 'text', + text: 'Let me check the weather for you.', + }, + { + type: 'tool_use', + id: 'toolu_01A09q90qw90lq917835lq9', + name: 'get_weather', + input: { location: 'San Francisco, CA' }, + }, + { + type: 'text', + text: 'The weather looks great!', + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 30, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 150, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model.invoke('What is the weather in San Francisco?', { + tools: [ + { + name: 'get_weather', + description: 'Get the current weather in a given location', + input_schema: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA', + }, + }, + required: ['location'], + }, + }, + ], + }); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs new file mode 100644 index 000000000000..2c60e55ff77e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs @@ -0,0 +1,110 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + if (model === 'error-model') { + res + .status(400) + .set('request-id', 'mock-request-123') + .json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'Model not found', + }, + }); + return; + } + + // Simulate basic response + res.json({ + id: 'msg_test123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Mock response from Anthropic!', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Test 1: Basic chat model invocation + const model1 = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model1.invoke('Tell me a joke'); + + // Test 2: Chat with different model + const model2 = new ChatAnthropic({ + model: 'claude-3-opus-20240229', + temperature: 0.9, + topP: 0.95, + maxTokens: 200, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model2.invoke([ + { role: 'system', content: 'You are a helpful assistant' }, + { role: 'user', content: 'What is the capital of France?' }, + ]); + + // Test 3: Error handling + const errorModel = new ChatAnthropic({ + model: 'error-model', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + try { + await errorModel.invoke('This will fail'); + } catch { + // Expected error + } + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..e3738b61b7a7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,197 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('LangChain integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with claude-3-5-sonnet + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with claude-3-opus + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + const EXPECTED_TRANSACTION_TOOL_CALLS = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 150, + 'gen_ai.usage.input_tokens': 20, + 'gen_ai.usage.output_tokens': 30, + 'gen_ai.usage.total_tokens': 50, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': 'tool_use', + 'gen_ai.response.tool_calls': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain spans with tool calls', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOL_CALLS }).start().completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 15158bdbb7bc..69ca79e04a17 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -31,6 +31,7 @@ export { contextLinesIntegration, continueTrace, createGetModuleFromFilename, + createLangChainCallbackHandler, createTransport, cron, dataloaderIntegration, @@ -93,6 +94,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, parameterize, pinoIntegration, postgresIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 5ff30f069486..da0393d9b0e9 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5ec1568229e4..33af15790191 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -62,6 +62,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -76,6 +77,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a6c5c2e17d3..f3b29009b9ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,6 +144,9 @@ export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export { instrumentGoogleGenAIClient } from './utils/google-genai'; export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; export type { GoogleGenAIResponse } from './utils/google-genai/types'; +export { createLangChainCallbackHandler } from './utils/langchain'; +export { LANGCHAIN_INTEGRATION_NAME } from './utils/langchain/constants'; +export type { LangChainOptions, LangChainIntegration } from './utils/langchain/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { AnthropicAiClient, diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index d55851927cb6..84efb21c1822 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -80,6 +80,11 @@ export const GEN_AI_RESPONSE_MODEL_ATTRIBUTE = 'gen_ai.response.model'; */ export const GEN_AI_RESPONSE_ID_ATTRIBUTE = 'gen_ai.response.id'; +/** + * The reason why the model stopped generating tokens + */ +export const GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE = 'gen_ai.response.stop_reason'; + /** * The number of tokens used in the prompt */ @@ -129,6 +134,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache creation input tokens used + */ +export const GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_creation_input_tokens'; + +/** + * The number of cache read input tokens used + */ +export const GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_read_input_tokens'; + /** * The number of cache write input tokens used */ diff --git a/packages/core/src/utils/langchain/constants.ts b/packages/core/src/utils/langchain/constants.ts new file mode 100644 index 000000000000..ead9bed623ad --- /dev/null +++ b/packages/core/src/utils/langchain/constants.ts @@ -0,0 +1,11 @@ +export const LANGCHAIN_INTEGRATION_NAME = 'LangChain'; +export const LANGCHAIN_ORIGIN = 'auto.ai.langchain'; + +export const ROLE_MAP: Record = { + human: 'user', + ai: 'assistant', + assistant: 'assistant', + system: 'system', + function: 'function', + tool: 'tool', +}; diff --git a/packages/core/src/utils/langchain/index.ts b/packages/core/src/utils/langchain/index.ts new file mode 100644 index 000000000000..1930be794be5 --- /dev/null +++ b/packages/core/src/utils/langchain/index.ts @@ -0,0 +1,321 @@ +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpanManual } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE } from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN } from './constants'; +import type { + LangChainCallbackHandler, + LangChainLLMResult, + LangChainMessage, + LangChainOptions, + LangChainSerialized, +} from './types'; +import { + extractChatModelRequestAttributes, + extractLLMRequestAttributes, + extractLlmResponseAttributes, + getInvocationParams, +} from './utils'; + +/** + * Creates a Sentry callback handler for LangChain + * Returns a plain object that LangChain will call via duck-typing + * + * This is a stateful handler that tracks spans across multiple LangChain executions. + */ +export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { + const recordInputs = options.recordInputs ?? false; + const recordOutputs = options.recordOutputs ?? false; + + // Internal state - single instance tracks all spans + const spanMap = new Map(); + + /** + * Exit a span and clean up + */ + const exitSpan = (runId: string): void => { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.end(); + spanMap.delete(runId); + } + }; + + /** + * Handler for LLM Start + * This handler will be called by LangChain's callback handler when an LLM event is detected. + */ + const handler: LangChainCallbackHandler = { + // Required LangChain BaseCallbackHandler properties + lc_serializable: false, + lc_namespace: ['langchain_core', 'callbacks', 'sentry'], + lc_secrets: undefined, + lc_attributes: undefined, + lc_aliases: undefined, + lc_serializable_keys: undefined, + lc_id: ['langchain_core', 'callbacks', 'sentry'], + lc_kwargs: {}, + name: 'SentryCallbackHandler', + + // BaseCallbackHandlerInput boolean flags + ignoreLLM: false, + ignoreChain: false, + ignoreAgent: false, + ignoreRetriever: false, + ignoreCustomEvent: false, + raiseError: false, + awaitHandlers: true, + + handleLLMStart( + llm: unknown, + prompts: string[], + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractLLMRequestAttributes( + llm as LangChainSerialized, + prompts, + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.pipeline', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.pipeline', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chat Model Start Handler + handleChatModelStart( + llm: unknown, + messages: unknown, + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractChatModelRequestAttributes( + llm as LangChainSerialized, + messages as LangChainMessage[][], + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.chat', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // LLM End Handler - note: handleLLMEnd with capital LLM (used by both LLMs and chat models!) + handleLLMEnd( + output: unknown, + runId: string, + _parentRunId?: string, + _tags?: string[], + _extraParams?: Record, + ) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + const attributes = extractLlmResponseAttributes(output as LangChainLLMResult, recordOutputs); + if (attributes) { + span.setAttributes(attributes); + } + exitSpan(runId); + } + }, + + // LLM Error Handler - note: handleLLMError with capital LLM + handleLLMError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'llm_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.llm_error_handler`, + }, + }); + }, + + // Chain Start Handler + handleChainStart(chain: { name?: string }, inputs: Record, runId: string, _parentRunId?: string) { + const chainName = chain.name || 'unknown_chain'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', + 'langchain.chain.name': chainName, + }; + + // Add inputs if recordInputs is enabled + if (recordInputs) { + attributes['langchain.chain.inputs'] = JSON.stringify(inputs); + } + + startSpanManual( + { + name: `chain ${chainName}`, + op: 'gen_ai.invoke_agent', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chain End Handler + handleChainEnd(outputs: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add outputs if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'langchain.chain.outputs': JSON.stringify(outputs), + }); + } + exitSpan(runId); + } + }, + + // Chain Error Handler + handleChainError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'chain_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.chain_error_handler`, + }, + }); + }, + + // Tool Start Handler + handleToolStart(tool: { name?: string }, input: string, runId: string, _parentRunId?: string) { + const toolName = tool.name || 'unknown_tool'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + 'gen_ai.tool.name': toolName, + }; + + // Add input if recordInputs is enabled + if (recordInputs) { + attributes['gen_ai.tool.input'] = input; + } + + startSpanManual( + { + name: `execute_tool ${toolName}`, + op: 'gen_ai.execute_tool', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Tool End Handler + handleToolEnd(output: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add output if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'gen_ai.tool.output': JSON.stringify(output), + }); + } + exitSpan(runId); + } + }, + + // Tool Error Handler + handleToolError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'tool_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.tool_error_handler`, + }, + }); + }, + + // LangChain BaseCallbackHandler required methods + copy() { + return handler; + }, + + toJSON() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + + toJSONNotImplemented() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + }; + + return handler; +} diff --git a/packages/core/src/utils/langchain/types.ts b/packages/core/src/utils/langchain/types.ts new file mode 100644 index 000000000000..e08542eefd60 --- /dev/null +++ b/packages/core/src/utils/langchain/types.ts @@ -0,0 +1,208 @@ +/** + * Options for LangChain integration + */ +export interface LangChainOptions { + /** + * Whether to record input messages/prompts + * @default false (respects sendDefaultPii option) + */ + recordInputs?: boolean; + + /** + * Whether to record output text and responses + * @default false (respects sendDefaultPii option) + */ + recordOutputs?: boolean; +} + +/** + * LangChain Serialized type (compatible with @langchain/core) + * Uses general types to be compatible with LangChain's Serialized interface. + * This is a flexible interface that accepts any serialized LangChain object. + */ +export interface LangChainSerialized { + [key: string]: unknown; + lc?: number; + type?: string; + id?: string[]; + name?: string; + graph?: Record; + kwargs?: Record; +} + +/** + * LangChain message structure + * Supports both regular messages and LangChain serialized format + */ +export interface LangChainMessage { + [key: string]: unknown; + type?: string; + content?: string; + message?: { + content?: unknown[]; + type?: string; + }; + role?: string; + additional_kwargs?: Record; + // LangChain serialized format + lc?: number; + id?: string[]; + kwargs?: { + [key: string]: unknown; + content?: string; + additional_kwargs?: Record; + response_metadata?: Record; + }; +} + +/** + * LangChain LLM result structure + */ +export interface LangChainLLMResult { + [key: string]: unknown; + generations: Array< + Array<{ + text?: string; + message?: LangChainMessage; + generation_info?: { + [key: string]: unknown; + + finish_reason?: string; + logprobs?: unknown; + }; + }> + >; + llmOutput?: { + [key: string]: unknown; + tokenUsage?: { + completionTokens?: number; + promptTokens?: number; + totalTokens?: number; + }; + model_name?: string; + }; +} + +/** + * Integration interface for type safety + */ +export interface LangChainIntegration { + name: string; + options: LangChainOptions; +} + +/** + * LangChain callback handler interface + * Compatible with both BaseCallbackHandlerMethodsClass and BaseCallbackHandler from @langchain/core + * Uses general types and index signature for maximum compatibility across LangChain versions + */ +export interface LangChainCallbackHandler { + // Allow any additional properties for full compatibility + [key: string]: unknown; + + // LangChain BaseCallbackHandler class properties (matching the class interface exactly) + lc_serializable: boolean; + lc_namespace: ['langchain_core', 'callbacks', string]; + lc_secrets: { [key: string]: string } | undefined; + lc_attributes: { [key: string]: string } | undefined; + lc_aliases: { [key: string]: string } | undefined; + lc_serializable_keys: string[] | undefined; + lc_id: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lc_kwargs: { [key: string]: any }; + name: string; + + // BaseCallbackHandlerInput properties (required boolean flags) + ignoreLLM: boolean; + ignoreChain: boolean; + ignoreAgent: boolean; + ignoreRetriever: boolean; + ignoreCustomEvent: boolean; + raiseError: boolean; + awaitHandlers: boolean; + + // Callback handler methods (properties with function signatures) + // Using 'any' for parameters and return types to match LangChain's BaseCallbackHandler exactly + handleLLMStart?: ( + llm: unknown, + prompts: string[], + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleLLMNewToken?: ( + token: string, + idx: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + fields?: unknown, + ) => Promise | unknown; + handleLLMEnd?: ( + output: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleLLMError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleChainStart?: ( + chain: { name?: string }, + inputs: Record, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runType?: string, + runName?: string, + ) => Promise | unknown; + handleChainEnd?: ( + outputs: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleChainError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleToolStart?: ( + tool: { name?: string }, + input: string, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleToolEnd?: (output: unknown, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + handleToolError?: (error: Error, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + + // LangChain class methods (required for BaseCallbackHandler compatibility) + copy(): unknown; + toJSON(): Record; + toJSONNotImplemented(): unknown; +} diff --git a/packages/core/src/utils/langchain/utils.ts b/packages/core/src/utils/langchain/utils.ts new file mode 100644 index 000000000000..8464e71aecb0 --- /dev/null +++ b/packages/core/src/utils/langchain/utils.ts @@ -0,0 +1,424 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_OPERATION_NAME_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_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; +import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; + +/** + * 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: Record, key: string, value: unknown): void => { + if (value != null) target[key] = value as SpanAttributeValue; +}; + +/** + * 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: Record, key: string, value: unknown): void => { + 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: unknown): string { + 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' | + */ +function normalizeMessageRole(role: string): string { + 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: string): string { + 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 + */ +export function getInvocationParams(tags?: string[] | Record): Record | undefined { + if (!tags || Array.isArray(tags)) return undefined; + return tags.invocation_params as Record | undefined; +} + +/** + * 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 }` + */ +export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<{ role: string; content: string }> { + return messages.map(message => { + // 1) Prefer _getType() when present + const maybeGetType = (message as { _getType?: () => string })._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 as { constructor?: { name?: string } }).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: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const attrs: Record = {}; + + // 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: unknown, + modelName: unknown, + operation: 'pipeline' | 'chat', + serialized: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + 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. + */ +export function extractLLMRequestAttributes( + llm: LangChainSerialized, + prompts: string[], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + 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]`. + */ +export function extractChatModelRequestAttributes( + llm: LangChainSerialized, + langChainMessages: LangChainMessage[][], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + 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()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(normalized)); + } + + 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: LangChainMessage[][], attrs: Record): void { + const toolCalls: unknown[] = []; + 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 as { type: string }; + 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: LangChainLLMResult['llmOutput'], + attrs: Record, +): void { + if (!llmOutput) return; + + const tokenUsage = llmOutput.tokenUsage as + | { promptTokens?: number; completionTokens?: number; totalTokens?: number } + | undefined; + const anthropicUsage = llmOutput.usage as + | { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + } + | undefined; + + 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). + */ +export function extractLlmResponseAttributes( + llmResult: LangChainLLMResult, + recordOutputs: boolean, +): Record | undefined { + if (!llmResult) return; + + const attrs: Record = {}; + + if (Array.isArray(llmResult.generations)) { + const finishReasons = llmResult.generations + .flat() + .map(g => g.generation_info?.finish_reason) + .filter((r): r is string => 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 as LangChainMessage[][], 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 as { model_name?: string; model?: string; id?: string; stop_reason?: string }; + // Provider model identifier: `model_name` (OpenAI-style) or `model` (others) + const modelName = llmOutput?.model_name ?? llmOutput?.model; + if (modelName) setIfDefined(attrs, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, modelName); + + if (llmOutput?.id) { + setIfDefined(attrs, GEN_AI_RESPONSE_ID_ATTRIBUTE, llmOutput.id); + } + + if (llmOutput?.stop_reason) { + setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(llmOutput.stop_reason)); + } + + return attrs; +} diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index db52cf357a16..02e55c45a7ba 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index b599351b5124..e469fd75d2d2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -27,6 +27,7 @@ export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; +export { langChainIntegration } from './integrations/tracing/langchain'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, @@ -134,6 +135,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + createLangChainCallbackHandler, } from '@sentry/core'; export type { diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dd9d9ac8df2b..2782d7907349 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -13,6 +13,7 @@ import { hapiIntegration, instrumentHapi } from './hapi'; import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; +import { instrumentLangChain, langChainIntegration } from './langchain'; import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; @@ -56,6 +57,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { firebaseIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + langChainIntegration(), ]; } @@ -93,5 +95,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, + instrumentLangChain, ]; } diff --git a/packages/node/src/integrations/tracing/langchain/index.ts b/packages/node/src/integrations/tracing/langchain/index.ts new file mode 100644 index 000000000000..e575691b930f --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/index.ts @@ -0,0 +1,107 @@ +import type { IntegrationFn, LangChainOptions } from '@sentry/core'; +import { defineIntegration, LANGCHAIN_INTEGRATION_NAME } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryLangChainInstrumentation } from './instrumentation'; + +export const instrumentLangChain = generateInstrumentOnce( + LANGCHAIN_INTEGRATION_NAME, + options => new SentryLangChainInstrumentation(options), +); + +const _langChainIntegration = ((options: LangChainOptions = {}) => { + return { + name: LANGCHAIN_INTEGRATION_NAME, + setupOnce() { + instrumentLangChain(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for LangChain. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments LangChain runnable instances + * to capture telemetry data by injecting Sentry callback handlers into all LangChain calls. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * Sentry.init({ + * integrations: [Sentry.langChainIntegration()], + * sendDefaultPii: true, // Enable to record inputs/outputs + * }); + * + * // LangChain calls are automatically instrumented + * const model = new ChatOpenAI(); + * await model.invoke("What is the capital of France?"); + * ``` + * + * ## Manual Callback Handler + * + * You can also manually add the Sentry callback handler alongside other callbacks: + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * const sentryHandler = Sentry.createLangChainCallbackHandler({ + * recordInputs: true, + * recordOutputs: true + * }); + * + * const model = new ChatOpenAI(); + * await model.invoke( + * "What is the capital of France?", + * { callbacks: [sentryHandler, myOtherCallback] } + * ); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record input messages/prompts (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + * ## Supported Events + * + * The integration captures the following LangChain lifecycle events: + * - LLM/Chat Model: start, end, error + * - Chain: start, end, error + * - Tool: start, end, error + * + */ +export const langChainIntegration = defineIntegration(_langChainIntegration); diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts new file mode 100644 index 000000000000..f171a2dfb022 --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -0,0 +1,214 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import type { LangChainOptions } from '@sentry/core'; +import { createLangChainCallbackHandler, getClient, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.1.0 <1.0.0']; + +type LangChainInstrumentationOptions = InstrumentationConfig & LangChainOptions; + +/** + * Represents the patched shape of LangChain provider package exports + */ +interface PatchedLangChainExports { + [key: string]: unknown; +} + +/** + * Augments a callback handler list with Sentry's handler if not already present + */ +function augmentCallbackHandlers(handlers: unknown, sentryHandler: unknown): unknown { + // Handle null/undefined - return array with just our handler + if (!handlers) { + return [sentryHandler]; + } + + // If handlers is already an array + if (Array.isArray(handlers)) { + // Check if our handler is already in the list + if (handlers.includes(sentryHandler)) { + return handlers; + } + // Add our handler to the list + return [...handlers, sentryHandler]; + } + + // If it's a single handler object, convert to array + if (typeof handlers === 'object') { + return [handlers, sentryHandler]; + } + + // Unknown type - return original + return handlers; +} + +/** + * Wraps Runnable methods (invoke, stream, batch) to inject Sentry callbacks at request time + * Uses a Proxy to intercept method calls and augment the options.callbacks + */ +function wrapRunnableMethod( + originalMethod: (...args: unknown[]) => unknown, + sentryHandler: unknown, + _methodName: string, +): (...args: unknown[]) => unknown { + return new Proxy(originalMethod, { + apply(target, thisArg, args: unknown[]): unknown { + // LangChain Runnable method signatures: + // invoke(input, options?) - options contains callbacks + // stream(input, options?) - options contains callbacks + // batch(inputs, options?) - options contains callbacks + + // Options is typically the second argument + const optionsIndex = 1; + let options = args[optionsIndex] as Record | undefined; + + // If options don't exist or aren't an object, create them + if (!options || typeof options !== 'object' || Array.isArray(options)) { + options = {}; + args[optionsIndex] = options; + } + + // Inject our callback handler into options.callbacks (request time callbacks) + const existingCallbacks = options.callbacks; + const augmentedCallbacks = augmentCallbackHandlers(existingCallbacks, sentryHandler); + options.callbacks = augmentedCallbacks; + + // Call original method with augmented options + return Reflect.apply(target, thisArg, args); + }, + }) as (...args: unknown[]) => unknown; +} + +/** + * Sentry LangChain instrumentation using OpenTelemetry. + */ +export class SentryLangChainInstrumentation extends InstrumentationBase { + public constructor(config: LangChainInstrumentationOptions = {}) { + super('@sentry/instrumentation-langchain', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + * We patch the BaseChatModel class methods to inject callbacks + * + * We hook into provider packages (@langchain/anthropic, @langchain/openai, etc.) + * because @langchain/core is often bundled and not loaded as a separate module + */ + public init(): InstrumentationModuleDefinition | InstrumentationModuleDefinition[] { + const modules: InstrumentationModuleDefinition[] = []; + + // Hook into common LangChain provider packages + const providerPackages = [ + '@langchain/anthropic', + '@langchain/openai', + '@langchain/google-genai', + '@langchain/mistralai', + '@langchain/google-vertexai', + '@langchain/groq', + ]; + + for (const packageName of providerPackages) { + // In CJS, LangChain packages re-export from dist/index.cjs files. + // Patching only the root module sometimes misses the real implementation or + // gets overwritten when that file is loaded. We add a file-level patch so that + // _patch runs again on the concrete implementation + modules.push( + new InstrumentationNodeModuleDefinition( + packageName, + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + `${packageName}/dist/index.cjs`, + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + ); + } + + return modules; + } + + /** + * Core patch logic - patches chat model methods to inject Sentry callbacks + * This is called when a LangChain provider package is loaded + */ + private _patch(exports: PatchedLangChainExports): PatchedLangChainExports | void { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const config = this.getConfig(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordInputs = config?.recordInputs ?? defaultPii; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordOutputs = config?.recordOutputs ?? defaultPii; + + // Create a shared handler instance + const sentryHandler = createLangChainCallbackHandler({ + recordInputs, + recordOutputs, + }); + + // Patch Runnable methods to inject callbacks at request time + // This directly manipulates options.callbacks that LangChain uses + this._patchRunnableMethods(exports, sentryHandler); + + return exports; + } + + /** + * Patches chat model methods (invoke, stream, batch) to inject Sentry callbacks + * Finds a chat model class from the provider package exports and patches its prototype methods + */ + private _patchRunnableMethods(exports: PatchedLangChainExports, sentryHandler: unknown): void { + // Known chat model class names for each provider + const knownChatModelNames = [ + 'ChatAnthropic', + 'ChatOpenAI', + 'ChatGoogleGenerativeAI', + 'ChatMistralAI', + 'ChatVertexAI', + 'ChatGroq', + ]; + + // Find a chat model class in the exports by checking known class names + const chatModelClass = Object.values(exports).find(exp => { + if (typeof exp !== 'function') { + return false; + } + return knownChatModelNames.includes(exp.name); + }) as { prototype: unknown; name: string } | undefined; + + if (!chatModelClass) { + return; + } + + // Patch directly on chatModelClass.prototype + const targetProto = chatModelClass.prototype as Record; + + // Patch the methods (invoke, stream, batch) + // All chat model instances will inherit these patched methods + const methodsToPatch = ['invoke', 'stream', 'batch'] as const; + + for (const methodName of methodsToPatch) { + const method = targetProto[methodName]; + if (typeof method === 'function') { + targetProto[methodName] = wrapRunnableMethod( + method as (...args: unknown[]) => unknown, + sentryHandler, + methodName, + ); + } + } + } +} diff --git a/yarn.lock b/yarn.lock index 4a6077f16b4b..7a38d3f22cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,13 @@ dependencies: json-schema-to-ts "^3.1.1" +"@anthropic-ai/sdk@^0.65.0": + version "0.65.0" + resolved "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz#3f464fe2029eacf8e7e7fb8197579d00c8ca7502" + integrity sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw== + dependencies: + json-schema-to-ts "^3.1.1" + "@apm-js-collab/code-transformer@^0.8.0", "@apm-js-collab/code-transformer@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz#a3160f16d1c4df9cb81303527287ad18d00994d1" @@ -2678,6 +2685,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + "@cloudflare/kv-asset-handler@0.4.0", "@cloudflare/kv-asset-handler@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz#a8588c6a2e89bb3e87fb449295a901c9f6d3e1bf" @@ -4888,6 +4900,32 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@langchain/anthropic@^0.3.10": + version "0.3.31" + resolved "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.31.tgz#80bc2464ab98cfb8df0de50cf219d92cfe5934e1" + integrity sha512-XyjwE1mA1I6sirSlVZtI6tyv7nH3+b8F5IFDi9WNKA8+SidJ0o3cP90TxrK7x1sSLmdj+su3f8s2hOusw6xpaw== + dependencies: + "@anthropic-ai/sdk" "^0.65.0" + fast-xml-parser "^4.4.1" + +"@langchain/core@^0.3.28": + version "0.3.78" + resolved "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz#40e69fba6688858edbcab4473358ec7affc685fd" + integrity sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA== + dependencies: + "@cfworker/json-schema" "^4.0.2" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.3.67" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^10.0.0" + zod "^3.25.32" + zod-to-json-schema "^3.22.3" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -9037,6 +9075,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/webidl-conversions@*": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" @@ -11414,7 +11457,7 @@ base64-arraybuffer@^1.0.1: resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -12488,16 +12531,16 @@ camelcase@5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== +camelcase@6, camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - camelcase@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" @@ -13220,6 +13263,13 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +console-table-printer@^2.12.1: + version "2.14.6" + resolved "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436" + integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw== + dependencies: + simple-wcswidth "^1.0.1" + console-ui@^3.0.4, console-ui@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/console-ui/-/console-ui-3.1.2.tgz#51aef616ff02013c85ccee6a6d77ef7a94202e7a" @@ -13936,7 +13986,7 @@ decamelize-keys@^1.1.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0: +decamelize@1.2.0, decamelize@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -19988,6 +20038,13 @@ js-string-escape@^1.0.1: resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -20392,6 +20449,19 @@ lambda-local@^2.2.0: dotenv "^16.3.1" winston "^3.10.0" +langsmith@^0.3.67: + version "0.3.74" + resolved "https://registry.npmjs.org/langsmith/-/langsmith-0.3.74.tgz#014d31a9ff7530b54f0d797502abd512ce8fb6fb" + integrity sha512-ZuW3Qawz8w88XcuCRH91yTp6lsdGuwzRqZ5J0Hf5q/AjMz7DwcSv0MkE6V5W+8hFMI850QZN2Wlxwm3R9lHlZg== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^4.1.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + p-retry "4" + semver "^7.6.3" + uuid "^10.0.0" + language-subtag-registry@~0.3.2: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -24066,7 +24136,7 @@ p-pipe@3.1.0: resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== -p-queue@6.6.2: +p-queue@6.6.2, p-queue@^6.6.2: version "6.6.2" resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== @@ -24087,7 +24157,7 @@ p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== -p-retry@^4.5.0: +p-retry@4, p-retry@^4.5.0: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== @@ -27720,6 +27790,11 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" +simple-wcswidth@^1.0.1: + version "1.1.2" + resolved "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" + integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -30441,6 +30516,11 @@ uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" @@ -31856,20 +31936,20 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" -zod-to-json-schema@^3.24.1: - version "3.24.5" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" - integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.24.1: + version "3.24.6" + resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" + integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== zod@3.22.3: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== -zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1: - version "3.25.75" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.75.tgz#8ff9be2fbbcb381a9236f9f74a8879ca29dcc504" - integrity sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg== +zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1, zod@^3.25.32: + version "3.25.76" + resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== zone.js@^0.12.0: version "0.12.0" From f895f09b529f743b80cdfcea5e11d7c26a9384b7 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Oct 2025 15:21:24 +0200 Subject: [PATCH 04/19] fix(node): Pino child loggers (#17934) This PR: - Includes bindings from child loggers as attributes - Tests that track/untrack setting is propagated to child loggers --- .../suites/pino/scenario-track.mjs | 13 +++++- .../suites/pino/scenario.mjs | 3 +- .../suites/pino/test.ts | 44 +++++++++---------- packages/node-core/src/integrations/pino.ts | 38 ++++++++++------ 4 files changed, 60 insertions(+), 38 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs index 2e968444a74f..e55f11f9c00c 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -17,7 +17,18 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + // This child should be captured as we marked the parent logger to be tracked + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); + + // This child should be ignored + const child2 = logger.child({ module: 'authentication.v2' }); + Sentry.pinoIntegration.untrackLogger(child2); + child2.error(new Error('oh no v2')); + + // This should also be ignored as the parent is ignored + const child3 = child2.child({ module: 'authentication.v3' }); + child3.error(new Error('oh no v3')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index beb080ac3c42..55966552a07f 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -17,7 +17,8 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 1982c8d686fc..f9cdb143ddff 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -45,7 +45,6 @@ conditionalTest({ min: 20 })('Pino integration', () => { function: '?', in_app: true, module: 'scenario', - context_line: " logger.error(new Error('oh no'));", }), ]), }, @@ -63,8 +62,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { @@ -74,7 +73,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -82,14 +81,14 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, @@ -139,8 +138,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { @@ -150,7 +149,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -158,14 +157,13 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, @@ -189,18 +187,19 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, + msg: { value: 'hello world', type: 'string' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -208,14 +207,15 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index dfc51d5022ff..7c3e0c2c813f 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -81,9 +81,23 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; +type PinoResult = { + level?: string; + time?: string; + pid?: number; + hostname?: string; + err?: Error; +} & Record; + +function stripIgnoredFields(result: PinoResult): PinoResult { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { level, time, pid, hostname, err, ...rest } = result; + return rest; +} + const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { - autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, + autoInstrument: userOptions.autoInstrument !== false, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; @@ -112,27 +126,23 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { - ...obj, + ...resultObj, 'sentry.origin': 'auto.logging.pino', 'pino.logger.level': levelNumber, }; - const parsedResult = JSON.parse(result) as { name?: string }; - - if (parsedResult.name) { - attributes['pino.logger.name'] = parsedResult.name; - } - _INTERNAL_captureLog({ level, message, attributes }); } @@ -153,8 +163,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial { const { self, arguments: args, result } = data as { self: Pino; arguments: PinoHookArgs; result: string }; - onPinoStart(self, args, result); + onPinoStart(self, args, JSON.parse(result)); }); integratedChannel.end.subscribe(data => { @@ -174,7 +184,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial Date: Wed, 22 Oct 2025 15:42:41 +0200 Subject: [PATCH 05/19] test(hono): Fix hono e2e tests (#18000) ATM there are failing Hono E2E tests (e.g. [here](https://github.com/getsentry/sentry-javascript/actions/runs/18714106732/job/53370082672?pr=17998)), which print out following: ```sh Error: src/index.ts(20,3): error TS2584: Cannot find name 'console'. Do you need to change your target library? Try changing the 'lib' compiler option to include 'dom'. ``` The reason was, that the types were not there yet. I just wonder why it was working before. In follow up PRs I will try to update Cloudflare tests with its integrations. --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 1 + .../e2e-tests/test-applications/cloudflare-hono/tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index a8fe024e9405..b005398a5faf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", "@cloudflare/workers-types": "^4.20250521.0", + "typescript": "^5.9.3", "vitest": "3.1.0", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json index e5d6f2b66f33..3c1c64b66cb8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "lib": ["ESNext"], "jsx": "react-jsx", + "types": ["@cloudflare/workers-types/experimental"], "jsxImportSource": "hono/jsx" }, "include": ["src/**/*"], From e05acdd5099e5c4dd2190452c96284460239177c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Oct 2025 16:19:48 +0200 Subject: [PATCH 06/19] fix(node): Pino capture serialized `err` (#17999) --- dev-packages/node-integration-tests/suites/pino/test.ts | 5 +++++ packages/node-core/src/integrations/pino.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index f9cdb143ddff..19fb5d80387a 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -84,6 +84,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { attributes: { name: { value: 'myapp', type: 'string' }, module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, @@ -159,6 +161,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { severity_number: 17, attributes: { name: { value: 'myapp', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, @@ -211,6 +215,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { name: { value: 'myapp', type: 'string' }, module: { value: 'authentication', type: 'string' }, msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 7c3e0c2c813f..68c17ded1abe 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -86,12 +86,11 @@ type PinoResult = { time?: string; pid?: number; hostname?: string; - err?: Error; } & Record; function stripIgnoredFields(result: PinoResult): PinoResult { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { level, time, pid, hostname, err, ...rest } = result; + const { level, time, pid, hostname, ...rest } = result; return rest; } From 39f85b39b1c60aaf9b1c25e9ecf688c3c1903484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 10:08:41 +0200 Subject: [PATCH 07/19] feat: Align sentry origin with documentation (#17998) There were 2 major changes: - `auto.console.logging` -> `auto.log.console` - `auto.logging.*` -> `auto.log.*` This can go in already, I am just not sure if this should be a breaking change or a minor bump, since theoretically dashboards or bookmarked searches/groupings would be failing. (closes #17900) --- .../public-api/logger/integration/test.ts | 30 ++++++------ .../suites/winston/test.ts | 18 ++++---- .../suites/consola/test.ts | 46 +++++++++---------- .../suites/pino/test.ts | 12 ++--- .../suites/winston/test.ts | 18 ++++---- packages/core/src/integrations/consola.ts | 2 +- packages/core/src/logs/console-integration.ts | 2 +- .../test/lib/integrations/consola.test.ts | 20 ++++---- packages/node-core/src/integrations/pino.ts | 2 +- .../node-core/src/integrations/winston.ts | 2 +- 10 files changed, 76 insertions(+), 76 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 442800456f9b..dd4bd7e8ebc3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -30,7 +30,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.trace 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.trace {} {}', type: 'string' }, @@ -45,7 +45,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.debug 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.debug {} {}', type: 'string' }, @@ -60,7 +60,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.log 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.log {} {}', type: 'string' }, @@ -75,7 +75,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.info 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.info {} {}', type: 'string' }, @@ -90,7 +90,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.warn 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.warn {} {}', type: 'string' }, @@ -105,7 +105,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.error 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.error {} {}', type: 'string' }, @@ -120,7 +120,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Assertion failed: console.assert 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -132,7 +132,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object: {"key":"value","nested":{"prop":123}}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Object: {}', type: 'string' }, @@ -146,7 +146,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Array: [1,2,3,"string"]', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Array: {}', type: 'string' }, @@ -160,7 +160,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Mixed: prefix {"obj":true} [4,5,6] suffix', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, @@ -177,7 +177,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: '', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -189,7 +189,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'String substitution %s %d test 42', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -201,7 +201,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object substitution %o {"key":"value"}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -213,7 +213,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'first 0 1 2', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'first {} {} {}', type: 'string' }, @@ -229,7 +229,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'hello true null undefined', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, diff --git a/dev-packages/node-core-integration-tests/suites/winston/test.ts b/dev-packages/node-core-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index cf396e319c51..2ee47a17dd20 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -18,7 +18,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -35,7 +35,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -52,7 +52,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -83,7 +83,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -100,7 +100,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -117,7 +117,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -135,7 +135,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -152,7 +152,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -169,7 +169,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -186,7 +186,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -203,7 +203,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -220,7 +220,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -236,7 +236,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -253,7 +253,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -283,7 +283,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -300,7 +300,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -342,7 +342,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -360,7 +360,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -392,7 +392,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -410,7 +410,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -440,7 +440,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -457,7 +457,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -474,7 +474,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 19fb5d80387a..a2ec57b57e56 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -70,7 +70,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -87,7 +87,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -148,7 +148,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -164,7 +164,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -200,7 +200,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { value: '{"more":3,"complex":"nope"}', }, msg: { value: 'hello world', type: 'string' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -217,7 +217,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 1caa7d2f212f..4781b253b161 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -217,7 +217,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const message = messageParts.join(' '); // Build attributes - attributes['sentry.origin'] = 'auto.logging.consola'; + attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { attributes['consola.tag'] = tag; diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index bf49c745e788..ccf14e3ebf48 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -16,7 +16,7 @@ interface CaptureConsoleOptions { const INTEGRATION_NAME = 'ConsoleLogs'; const DEFAULT_ATTRIBUTES = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.console.logging', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.log.console', }; const _consoleLoggingIntegration = ((options: Partial = {}) => { diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index a5c68184e03b..a32f073eeb75 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -78,7 +78,7 @@ describe('createConsolaReporter', () => { level: 'error', message: 'This is an error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.tag': 'test', 'consola.type': 'error', 'consola.level': 0, @@ -98,7 +98,7 @@ describe('createConsolaReporter', () => { level: 'warn', message: 'This is a warning', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'warn', }, }); @@ -116,7 +116,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'This is info', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -134,7 +134,7 @@ describe('createConsolaReporter', () => { level: 'debug', message: 'Debug message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'debug', }, }); @@ -152,7 +152,7 @@ describe('createConsolaReporter', () => { level: 'trace', message: 'Trace message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'trace', }, }); @@ -170,7 +170,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'fatal', }, }); @@ -189,7 +189,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Hello world 123 {"key":"value"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -210,7 +210,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Message {"self":"[Circular ~]"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -228,7 +228,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.level': 0, }, }); @@ -257,7 +257,7 @@ describe('createConsolaReporter', () => { level: expectedLevel, message: `Test ${type} message`, attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': type, }, }); diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 68c17ded1abe..21eeff64769e 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -138,7 +138,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { ...resultObj, - 'sentry.origin': 'auto.logging.pino', + 'sentry.origin': 'auto.log.pino', 'pino.logger.level': levelNumber, }; diff --git a/packages/node-core/src/integrations/winston.ts b/packages/node-core/src/integrations/winston.ts index 63e208920914..bea0fa584bf7 100644 --- a/packages/node-core/src/integrations/winston.ts +++ b/packages/node-core/src/integrations/winston.ts @@ -89,7 +89,7 @@ export function createSentryWinstonTransport Date: Thu, 23 Oct 2025 10:30:15 +0200 Subject: [PATCH 08/19] feat(node): Pass requestHook and responseHook option to OTel (#17996) This adds two new options into the `nativeNodeFetchIntegration` - the only thing it does is passing the two new options directly into the OTel instrumentation. Since this is OTel related, this is only accessible within the `node` SDK. The documentation will be then updated for the fetch integration ([it seems](https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/nodefetch/) that also the `spans` are missing) (closes #17953) --- .../fetch-forward-request-hook/scenario.ts | 26 +++++++++ .../fetch-forward-request-hook/test.ts | 58 +++++++++++++++++++ packages/node/src/integrations/node-fetch.ts | 4 +- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts new file mode 100644 index 000000000000..0843830321c4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.nativeNodeFetchIntegration({ + requestHook: (span, req) => { + span.setAttribute('sentry.request.hook', req.path); + }, + responseHook: (span, { response, request }) => { + span.setAttribute('sentry.response.hook.path', request.path); + span.setAttribute('sentry.response.hook.status_code', response.statusCode); + }, + }), + ], +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + await fetch(`${process.env.SERVER_URL}/api/v1`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts new file mode 100644 index 000000000000..8d0a35a43d05 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('adds requestHook and responseHook attributes to spans of outgoing fetch requests', async () => { + expect.assertions(3); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .get( + '/api/v1', + () => { + // Just ensure we're called + expect(true).toBe(true); + }, + 404, + ) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + transaction: 'test_transaction', + spans: [ + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v0/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v0', + 'sentry.response.hook.path': '/api/v0', + 'sentry.response.hook.status_code': 200, + }), + }), + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v1/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'not_found', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v1', + 'sentry.response.hook.path': '/api/v1', + 'sentry.response.hook.status_code': 404, + 'http.response.status_code': 404, + }), + }), + ], + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts index 437806e16dbc..6da9fd628bac 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -8,7 +8,7 @@ import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'NodeFetch'; -interface NodeFetchOptions { +interface NodeFetchOptions extends Pick { /** * Whether breadcrumbs should be recorded for requests. * Defaults to true @@ -106,6 +106,8 @@ function getConfigWithDefaults(options: Partial = {}): UndiciI [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', }; }, + requestHook: options.requestHook, + responseHook: options.responseHook, } satisfies UndiciInstrumentationConfig; return instrumentationConfig; From 152b9d4b573e5143fa674db4d15f98ee56685477 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 13:22:22 +0200 Subject: [PATCH 09/19] feat(nextjs): Support node runtime on proxy files (#17995) [Next 16 was released](https://github.com/vercel/next.js/releases/tag/v16.0.0) With that proxy files run per default on nodejs. This PR - Updates the tests to run on next 16 (non-beta) - Adds support for handling middleware transactions in the node part of the sdk --- .../test-applications/nextjs-16/package.json | 13 +++- .../nextjs-16/tests/middleware.test.ts | 65 +++++++++++-------- .../nextjs/src/common/nextSpanAttributes.ts | 3 + packages/nextjs/src/server/index.ts | 39 ++++++++--- 4 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 packages/nextjs/src/common/nextSpanAttributes.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 2da23b152807..af9f306f017d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -16,6 +16,8 @@ "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" @@ -25,7 +27,7 @@ "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.0.0-beta.0", + "next": "16.0.0", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", @@ -50,6 +52,15 @@ "build-command": "pnpm test:build-webpack", "label": "nextjs-16 (webpack)", "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest-webpack", + "label": "nextjs-16 (latest, webpack)", + "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest", + "label": "nextjs-16 (latest, turbopack)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 4ed289eb6215..aa4611fb7afc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { @@ -13,8 +14,8 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); @@ -22,6 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { }); test('Faulty middlewares', async ({ request }) => { + test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET'; }); @@ -36,27 +38,29 @@ test('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); - await test.step('should record exceptions', async () => { - const errorEvent = await errorEventPromise; - - // Assert that isolation scope works properly - expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); - expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect([ - 'middleware GET', // non-otel webpack versions - '/middleware', // middleware file - '/proxy', // proxy file - ]).toContain(errorEvent.transaction); - }); + // TODO: proxy errors currently not reported via onRequestError + // await test.step('should record exceptions', async () => { + // const errorEvent = await errorEventPromise; + + // // Assert that isolation scope works properly + // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // expect([ + // 'middleware GET', // non-otel webpack versions + // '/middleware', // middleware file + // '/proxy', // proxy file + // ]).toContain(errorEvent.transaction); + // }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware GET' && @@ -74,18 +78,26 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru expect.arrayContaining([ { data: { - 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', 'http.response.status_code': 200, - type: 'fetch', - url: 'http://localhost:3030/', - 'http.url': 'http://localhost:3030/', - 'server.address': 'localhost:3030', + 'network.peer.address': '::1', + 'network.peer.port': 3030, + 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.wintercg_fetch', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'server.address': 'localhost', + 'server.port': 3030, + url: 'http://localhost:3030/', + 'url.full': 'http://localhost:3030/', + 'url.path': '/', + 'url.query': '', + 'url.scheme': 'http', + 'user_agent.original': 'node', }, description: 'GET http://localhost:3030/', op: 'http.client', - origin: 'auto.http.wintercg_fetch', + origin: 'auto.http.otel.node_fetch', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -95,11 +107,12 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru }, ]), ); + expect(middlewareTransaction.breadcrumbs).toEqual( expect.arrayContaining([ { - category: 'fetch', - data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + category: 'http', + data: { 'http.method': 'GET', status_code: 200, url: 'http://localhost:3030/' }, timestamp: expect.any(Number), type: 'http', }, diff --git a/packages/nextjs/src/common/nextSpanAttributes.ts b/packages/nextjs/src/common/nextSpanAttributes.ts new file mode 100644 index 000000000000..8b9f4a9d1374 --- /dev/null +++ b/packages/nextjs/src/common/nextSpanAttributes.ts @@ -0,0 +1,3 @@ +export const ATTR_NEXT_SPAN_TYPE = 'next.span_type'; +export const ATTR_NEXT_SPAN_NAME = 'next.span_name'; +export const ATTR_NEXT_ROUTE = 'next.route'; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 5ce23e6a9460..aa6210c2ff6a 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -31,6 +31,7 @@ import { getScopesFromContext } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, @@ -169,7 +170,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. - if (typeof spanAttributes?.['next.route'] === 'string') { + if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { const rootSpanAttributes = spanToJSON(rootSpan).data; // Only hoist the http.route attribute if the transaction doesn't already have it if ( @@ -177,17 +178,27 @@ export function init(options: NodeOptions): NodeClient | undefined { (rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) && !rootSpanAttributes?.[ATTR_HTTP_ROUTE] ) { - const route = spanAttributes['next.route'].replace(/\/route$/, ''); + const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, ''); rootSpan.updateName(route); rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); // Preserving the original attribute despite internally not depending on it - rootSpan.setAttribute('next.route', route); + rootSpan.setAttribute(ATTR_NEXT_ROUTE, route); } } + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') { + const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME]; + if (typeof middlewareName === 'string') { + rootSpan.updateName(middlewareName); + rootSpan.setAttribute(ATTR_HTTP_ROUTE, middlewareName); + rootSpan.setAttribute(ATTR_NEXT_SPAN_NAME, middlewareName); + } + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); + } + // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans // with patterns (e.g. http.server spans) that will produce confusing data. - if (spanAttributes?.['next.span_type'] !== undefined) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } @@ -197,7 +208,7 @@ export function init(options: NodeOptions): NodeClient | undefined { } // We want to fork the isolation scope for incoming requests - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); @@ -320,7 +331,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // Enhance route handler transactions if ( event.type === 'transaction' && - event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest' + event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' ) { event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; event.contexts.trace.op = 'http.server'; @@ -333,14 +344,15 @@ export function init(options: NodeOptions): NodeClient | undefined { const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; // eslint-disable-next-line deprecation/deprecation const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route']; + const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; + const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - if (typeof method === 'string' && typeof route === 'string') { + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { const cleanRoute = route.replace(/\/route$/, ''); event.transaction = `${method} ${cleanRoute}`; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; // Preserve next.route in case it did not get hoisted - event.contexts.trace.data['next.route'] = cleanRoute; + event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; } // backfill transaction name for pages that would otherwise contain unparameterized routes @@ -348,6 +360,15 @@ export function init(options: NodeOptions): NodeClient | undefined { event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; } + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + const normalizedName = `middleware ${middlewareMatch[1]}`; + event.transaction = normalizedName; + event.contexts.trace.op = 'http.server.middleware'; + } + // Next.js overrides transaction names for page loads that throw an error // but we want to keep the original target name if (event.transaction === 'GET /_error' && target) { From 43b383c63d79147b0ca8c0109c6b1c4abf64484c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 13:53:48 +0200 Subject: [PATCH 10/19] feat(firebase): Instrument cloud functions for firebase v2 (#17952) Closes: #17861 This adds - instrumentation of Cloud Functions for Firebase (v2) along side the Firestore integration. It can be used with the `Sentry.firebaseIntegration()` (this is atm not documented in the docs and got added in #16719, but will be added right after this has been merged. See https://github.com/getsentry/sentry-docs/issues/15247). - The test app for Firebase has been rewritten and updated since it requires a little special setup.
Supported functions
  • onRequest
  • onCall
  • onDocumentCreated
  • onDocumentUpdated
  • onDocumentDeleted
  • onDocumentWritten
  • onDocumentCreatedWithAuthContext
  • onDocumentUpdatedWithAuthContext
  • onDocumentDeletedWithAuthContext
  • onDocumentWrittenWithAuthContext
  • onSchedule
  • onObjectFinalized
  • onObjectArchived
  • onObjectDeleted
  • onObjectMetadataUpdated
Bear in mind that the OTel attributes for FaaS are still in [Development](https://opentelemetry.io/docs/specs/semconv/faas/faas-spans/) and could change or be removed over time (not sure if we should then even add them in here at this point in time). --- .size-limit.js | 2 +- .../node-firebase/.firebaserc | 5 - .../test-applications/node-firebase/README.md | 80 +++-- .../node-firebase/firebase.json | 9 +- .../node-firebase/firestore-app/package.json | 20 ++ .../{ => firestore-app}/src/app.ts | 0 .../{ => firestore-app}/src/init.ts | 0 .../node-firebase/firestore-app/tsconfig.json | 8 + .../node-firebase/functions/package.json | 19 ++ .../node-firebase/functions/src/index.ts | 50 ++++ .../node-firebase/functions/src/init.ts | 10 + .../node-firebase/functions/tsconfig.json | 8 + .../node-firebase/package.json | 26 +- .../node-firebase/pnpm-workspace.yaml | 3 + .../node-firebase/tests/functions.test.ts | 150 ++++++++++ .../node-firebase/tsconfig.build.json | 4 - .../node-firebase/tsconfig.json | 6 +- .../integrations/tracing/firebase/firebase.ts | 20 +- .../firebase/otel/firebaseInstrumentation.ts | 3 + .../firebase/otel/patches/functions.ts | 276 ++++++++++++++++++ .../tracing/firebase/otel/types.ts | 48 +++ 21 files changed, 667 insertions(+), 80 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json rename dev-packages/e2e-tests/test-applications/node-firebase/{ => firestore-app}/src/app.ts (100%) rename dev-packages/e2e-tests/test-applications/node-firebase/{ => firestore-app}/src/init.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json create mode 100644 packages/node/src/integrations/tracing/firebase/otel/patches/functions.ts diff --git a/.size-limit.js b/.size-limit.js index 269ce49b1cc1..7106f2e29b03 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '157 KB', + limit: '158 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc deleted file mode 100644 index 47e4665f6905..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "sentry-firebase-e2e-test-f4ed3" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md index e44ee12f5268..bd91bd5a872a 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/README.md +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -1,64 +1,50 @@ -## Assuming you already have installed docker desktop or orbstack etc. or any other docker software +
+ + Firebase + +
-### Enabling / authorising firebase emulator through docker +## Description -1. Run the docker +[Firebase](https://firebase.google.com/) starter repository with Cloud Functions for Firebase and Firestore. -```bash -pnpm docker -``` - -2. In new tab, enter the docker container by simply running +## Project setup -```bash -docker exec -it sentry-firebase bash +```sh +$ pnpm install ``` -3. Now inside docker container run +## Compile and run the project -```bash -firebase login +```sh +$ pnpm dev # builds the functions and firestore app +$ pnpm emulate +$ pnpm start # run the firestore app ``` -4. You should now see a long link to authenticate with google account, copy the link and open it using your browser -5. Choose the account you want to authenticate with -6. Once you do this you should be able to see something like "Firebase CLI Login Successful" -7. And inside docker container you should see something like "Success! Logged in as " -8. Now you can exit docker container - -```bash -exit -``` +## Run tests -9. Switch back to previous tab, stop the docker container (ctrl+c). -10. You should now be able to run the test, as you have correctly authenticated the firebase emulator +Either run the tests directly: -### Preparing data for CLI - -1. Please authorize the docker first - see the previous section -2. Once you do that you can generate .env file locally, to do that just run - -```bash -npm run createEnvFromConfig +```sh +$ pnpm test:build +$ pnpm test:assert ``` -3. It will create a new file called ".env" inside folder "docker" -4. View the file. There will be 2 params CONFIG_FIREBASE_TOOLS and CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS. -5. Now inside the CLI create a new variable under the name CONFIG_FIREBASE_TOOLS and - CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS - take values from mentioned .env file -6. File .env is ignored to avoid situation when developer after authorizing firebase with private account will - accidently push the tokens to github. -7. But if we want the users to still have some default to be used for authorisation (on their local development) it will - be enough to commit this file, we just have to authorize it with some "special" account. +Or run develop while running the tests directly against the emulator. Start each script in a separate terminal: -**Some explanation towards environment settings, the environment variable defined directly in "environments" takes -precedence over .env file, that means it will be safe to define it in CLI and still keeps the .env file.** +```sh +$ pnpm dev +$ pnpm emulate +$ pnpm test --ui +``` -### Scripts - helpers +The tests will run against the Firebase Emulator Suite. -- createEnvFromConfig - it will use the firebase docker authentication and create .env file which will be used then by - docker whenever you run emulator -- createConfigFromEnv - it will use '.env' file in docker folder to create .config for the firebase to be used to - authenticate whenever you run docker, Docker by default loads .env file itself +## Resources -Use these scripts when testing and updating the environment settings on CLI +- [Firebase](https://firebase.google.com/) +- [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) +- [Firebase SDK](https://firebase.google.com/docs/sdk) +- [Firebase Functions](https://firebase.google.com/docs/functions) +- [Firestore](https://firebase.google.com/docs/firestore) diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json index 05203f1d6567..eb1b42b8aa9c 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -16,5 +16,12 @@ "enabled": true }, "singleProjectMode": true - } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"] + } + ] } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json new file mode 100644 index 000000000000..b5d19993bdae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "firestore-app", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "start": "node ./dist/app.js" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "express": "^4.18.2", + "firebase": "^12.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/node": "^22.13.14", + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json new file mode 100644 index 000000000000..c3be318b8c38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json @@ -0,0 +1,19 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch" + }, + "engines": { + "node": "20" + }, + "main": "dist/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "@sentry/node": "latest || *" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts new file mode 100644 index 000000000000..6a3df6f4a61a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts @@ -0,0 +1,50 @@ +import './init'; + +import { onDocumentCreated, onDocumentCreatedWithAuthContext } from 'firebase-functions/firestore'; +import { onRequest } from 'firebase-functions/https'; +import * as logger from 'firebase-functions/logger'; +import { setGlobalOptions } from 'firebase-functions/options'; +import * as admin from 'firebase-admin'; + +setGlobalOptions({ region: 'default' }); + +admin.initializeApp(); + +const db = admin.firestore(); + +export const helloWorld = onRequest(async (request, response) => { + logger.info('Hello logs!', { structuredData: true }); + + response.send('Hello from Firebase!'); +}); + +export const unhandeledError = onRequest(async (request, response) => { + throw new Error('There is an error!'); +}); + +export const onCallSomething = onRequest(async (request, response) => { + const data = { + name: request.body?.name || 'Sample Document', + timestamp: performance.now(), + description: request.body?.description || 'Created via Cloud Function', + }; + + await db.collection('documents').add(data); + + logger.info('Create document!', { structuredData: true }); + + response.send({ message: 'Document created!' }); +}); + +export const onDocumentCreate = onDocumentCreated('documents/{documentId}', async event => { + const documentId = event.params.documentId; + + await db.collection('documents').doc(documentId).update({ + processed: true, + processedAt: new Date(), + }); +}); + +export const onDocumentCreateWithAuthContext = onDocumentCreatedWithAuthContext('documents/{documentId}', async () => { + // noop +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts new file mode 100644 index 000000000000..c3b4a642375a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.firebaseIntegration()], + defaultIntegrations: false, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json index 0a23fbbeef92..41eb0ce085d4 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -3,34 +3,26 @@ "version": "0.0.1", "private": true, "scripts": { - "build": "tsc", - "dev": "tsc --build --watch", + "build": "pnpm run -r build", + "dev": "pnpm run -r dev", "proxy": "node start-event-proxy.mjs", - "emulate": "firebase emulators:start &", - "start": "node ./dist/app.js", + "emulate": "firebase emulators:start --project demo-functions", + "start": "pnpm run -r start", "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", + "clean": "npx rimraf node_modules **/node_modules pnpm-lock.yaml **/dist *-debug.log test-results", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm firebase emulators:exec 'pnpm test'" + "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'" }, "dependencies": { - "@firebase/app": "^0.13.1", - "@sentry/node": "latest || *", - "@sentry/core": "latest || *", - "@sentry/opentelemetry": "latest || *", - "@types/node": "^18.19.1", + "@types/node": "^22.13.14", "dotenv": "^16.4.5", - "express": "^4.18.2", - "firebase": "^12.0.0", - "firebase-admin": "^12.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "4.9.5" + "typescript": "5.9.3" }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/express": "^4.17.13", - "firebase-tools": "^12.0.0" + "firebase-tools": "^14.20.0" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml new file mode 100644 index 000000000000..8a5eb172e019 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'functions' + - 'firestore-app' diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts new file mode 100644 index 000000000000..2600b8bc1ec5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should only call the function once without any extra calls', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + await fetch(`http://localhost:5001/demo-functions/default/helloWorld`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts).toEqual( + expect.objectContaining({ + trace: expect.objectContaining({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'helloWorld', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + }), + ); +}); + +test('should send failed transaction when the function fails', async () => { + const errorEventPromise = waitForError('node-firebase', () => true); + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return !!span.transaction; + }); + + await fetch(`http://localhost:5001/demo-functions/default/unhandeledError`); + + const transactionEvent = await serverTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace?.trace_id).toEqual(errorEvent.contexts?.trace?.trace_id); + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'There is an error!', + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }, + ], + }, + }); +}); + +test('should create a document and trigger onDocumentCreated and another with authContext', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + const serverTransactionOnDocumentCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreate' + ); + }); + + const serverTransactionOnDocumentWithAuthContextCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreateWithAuthContext' + ); + }); + + await fetch(`http://localhost:5001/demo-functions/default/onCallSomething`); + + const transactionEvent = await serverTransactionPromise; + const transactionEventOnDocumentCreate = await serverTransactionOnDocumentCreatePromise; + const transactionEventOnDocumentWithAuthContextCreate = await serverTransactionOnDocumentWithAuthContextCreatePromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onCallSomething', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEvent.spans).toHaveLength(3); + expect(transactionEventOnDocumentCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreate', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentCreate.spans).toHaveLength(2); + expect(transactionEventOnDocumentWithAuthContextCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreateWithAuthContext', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentWithAuthContextCreate.spans).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json deleted file mode 100644 index 26c30d4eddf2..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json index 8cb64e989ed9..881847032511 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -3,8 +3,6 @@ "types": ["node"], "esModuleInterop": true, "lib": ["es2018"], - "strict": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts"] + "strict": true + } } diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts index 649a7089289b..ceb521d54fa3 100644 --- a/packages/node/src/integrations/tracing/firebase/firebase.ts +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { captureException, defineIntegration, flush, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; @@ -11,6 +11,24 @@ const config: FirebaseInstrumentationConfig = { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); }, + functions: { + requestHook: span => { + addOriginToSpan(span, 'auto.firebase.otel.functions'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request'); + }, + errorHook: async (_, error) => { + if (error) { + captureException(error, { + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }); + await flush(2000); + } + }, + }, }; export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts index ad67ea701079..724005e6f9ed 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -1,10 +1,12 @@ import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; import { SDK_VERSION } from '@sentry/core'; import { patchFirestore } from './patches/firestore'; +import { patchFunctions } from './patches/functions'; import type { FirebaseInstrumentationConfig } from './types'; const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ +const functionsSupportedVersions = ['>=6.0.0 <7']; // firebase-functions v2 /** * Instrumentation for Firebase services, specifically Firestore. @@ -31,6 +33,7 @@ export class FirebaseInstrumentation extends InstrumentationBase {}; + let responseHook: ResponseHook = () => {}; + const errorHook = config.functions?.errorHook; + const configRequestHook = config.functions?.requestHook; + const configResponseHook = config.functions?.responseHook; + + if (typeof configResponseHook === 'function') { + responseHook = (span: Span, err: unknown) => { + safeExecuteInTheMiddle( + () => configResponseHook(span, err), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + if (typeof configRequestHook === 'function') { + requestHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configRequestHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFunctionsCJS = new InstrumentationNodeModuleDefinition('firebase-functions', functionsSupportedVersions); + const modulesToInstrument = [ + { name: 'firebase-functions/lib/v2/providers/https.js', triggerType: 'function' }, + { name: 'firebase-functions/lib/v2/providers/firestore.js', triggerType: 'firestore' }, + { name: 'firebase-functions/lib/v2/providers/scheduler.js', triggerType: 'scheduler' }, + { name: 'firebase-functions/lib/v2/storage.js', triggerType: 'storage' }, + ] as const; + + modulesToInstrument.forEach(({ name, triggerType }) => { + moduleFunctionsCJS.files.push( + new InstrumentationNodeModuleFile( + name, + functionsSupportedVersions, + moduleExports => + wrapCommonFunctions( + moduleExports, + wrap, + unwrap, + tracer, + { requestHook, responseHook, errorHook }, + triggerType, + ), + moduleExports => unwrapCommonFunctions(moduleExports, unwrap), + ), + ); + }); + + return moduleFunctionsCJS; +} + +/** + * Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation + * + * @param tracer - Opentelemetry Tracer + * @param functionsConfig - Firebase instrumentation config + * @param triggerType - Type of trigger + * @returns A function that patches the function + */ +export function patchV2Functions( + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: string, +): (original: T) => (...args: OverloadedParameters) => ReturnType { + return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters) => ReturnType { + return function (this: FirebaseInstrumentation, ...args: OverloadedParameters): ReturnType { + const handler = typeof args[0] === 'function' ? args[0] : args[1]; + const documentOrOptions = typeof args[0] === 'function' ? undefined : args[0]; + + if (!handler) { + return original.call(this, ...args); + } + + const wrappedHandler = async function (this: unknown, ...handlerArgs: unknown[]): Promise { + const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + const span = tracer.startSpan(`firebase.function.${triggerType}`, { + kind: SpanKind.SERVER, + }); + + const attributes: SpanAttributes = { + 'faas.name': functionName, + 'faas.trigger': triggerType, + 'faas.provider': 'firebase', + }; + + if (process.env.GCLOUD_PROJECT) { + attributes['cloud.project_id'] = process.env.GCLOUD_PROJECT; + } + + if (process.env.EVENTARC_CLOUD_EVENT_SOURCE) { + attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE; + } + + span.setAttributes(attributes); + functionsConfig?.requestHook?.(span); + + // Can be changed to safeExecuteInTheMiddleAsync once following is merged and released + // https://github.com/open-telemetry/opentelemetry-js/pull/6032 + return context.with(trace.setSpan(context.active(), span), async () => { + let error: Error | undefined; + let result: T | undefined; + + try { + result = await handler.apply(this, handlerArgs); + } catch (e) { + error = e as Error; + } + + functionsConfig?.responseHook?.(span, error); + + if (error) { + span.recordException(error); + } + + span.end(); + + if (error) { + await functionsConfig?.errorHook?.(span, error); + throw error; + } + + return result; + }); + }; + + if (documentOrOptions) { + return original.call(this, documentOrOptions, wrappedHandler); + } else { + return original.call(this, wrappedHandler); + } + }; + }; +} + +function wrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + wrap: InstrumentationBase['_wrap'], + unwrap: InstrumentationBase['_unwrap'], + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: 'function' | 'firestore' | 'scheduler' | 'storage', +): AvailableFirebaseFunctions { + unwrapCommonFunctions(moduleExports, unwrap); + + switch (triggerType) { + case 'function': + wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsConfig, 'http.request')); + wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsConfig, 'http.call')); + break; + + case 'firestore': + wrap(moduleExports, 'onDocumentCreated', patchV2Functions(tracer, functionsConfig, 'firestore.document.created')); + wrap(moduleExports, 'onDocumentUpdated', patchV2Functions(tracer, functionsConfig, 'firestore.document.updated')); + wrap(moduleExports, 'onDocumentDeleted', patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted')); + wrap(moduleExports, 'onDocumentWritten', patchV2Functions(tracer, functionsConfig, 'firestore.document.written')); + wrap( + moduleExports, + 'onDocumentCreatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.created'), + ); + wrap( + moduleExports, + 'onDocumentUpdatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'), + ); + + wrap( + moduleExports, + 'onDocumentDeletedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'), + ); + + wrap( + moduleExports, + 'onDocumentWrittenWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.written'), + ); + break; + + case 'scheduler': + wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsConfig, 'scheduler.scheduled')); + break; + + case 'storage': + wrap(moduleExports, 'onObjectFinalized', patchV2Functions(tracer, functionsConfig, 'storage.object.finalized')); + wrap(moduleExports, 'onObjectArchived', patchV2Functions(tracer, functionsConfig, 'storage.object.archived')); + wrap(moduleExports, 'onObjectDeleted', patchV2Functions(tracer, functionsConfig, 'storage.object.deleted')); + wrap( + moduleExports, + 'onObjectMetadataUpdated', + patchV2Functions(tracer, functionsConfig, 'storage.object.metadataUpdated'), + ); + break; + } + + return moduleExports; +} + +function unwrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + unwrap: InstrumentationBase['_unwrap'], +): AvailableFirebaseFunctions { + const methods: (keyof AvailableFirebaseFunctions)[] = [ + 'onSchedule', + 'onRequest', + 'onCall', + 'onObjectFinalized', + 'onObjectArchived', + 'onObjectDeleted', + 'onObjectMetadataUpdated', + 'onDocumentCreated', + 'onDocumentUpdated', + 'onDocumentDeleted', + 'onDocumentWritten', + 'onDocumentCreatedWithAuthContext', + 'onDocumentUpdatedWithAuthContext', + 'onDocumentDeletedWithAuthContext', + 'onDocumentWrittenWithAuthContext', + ]; + + for (const method of methods) { + if (isWrapped(moduleExports[method])) { + unwrap(moduleExports, method); + } + } + return moduleExports; +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts index ecc48bc09498..ead830fa2c1a 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/types.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -88,8 +88,19 @@ export interface FirestoreSettings { */ export interface FirebaseInstrumentationConfig extends InstrumentationConfig { firestoreSpanCreationHook?: FirestoreSpanCreationHook; + functions?: FunctionsConfig; } +export interface FunctionsConfig { + requestHook?: RequestHook; + responseHook?: ResponseHook; + errorHook?: ErrorHook; +} + +export type RequestHook = (span: Span) => void; +export type ResponseHook = (span: Span, error?: unknown) => void; +export type ErrorHook = (span: Span, error?: unknown) => Promise | void; + export interface FirestoreSpanCreationHook { (span: Span): void; } @@ -117,3 +128,40 @@ export type AddDocType = ( export type DeleteDocType = ( reference: DocumentReference, ) => Promise; + +export type OverloadedParameters = T extends { + (...args: infer A1): unknown; + (...args: infer A2): unknown; +} + ? A1 | A2 + : T extends (...args: infer A) => unknown + ? A + : unknown; + +/** + * A bare minimum of how Cloud Functions for Firebase (v2) are defined. + */ +export type FirebaseFunctions = + | ((handler: () => Promise | unknown) => (...args: unknown[]) => Promise | unknown) + | (( + documentOrOptions: string | string[] | Record, + handler: () => Promise | unknown, + ) => (...args: unknown[]) => Promise | unknown); + +export type AvailableFirebaseFunctions = { + onRequest: FirebaseFunctions; + onCall: FirebaseFunctions; + onDocumentCreated: FirebaseFunctions; + onDocumentUpdated: FirebaseFunctions; + onDocumentDeleted: FirebaseFunctions; + onDocumentWritten: FirebaseFunctions; + onDocumentCreatedWithAuthContext: FirebaseFunctions; + onDocumentUpdatedWithAuthContext: FirebaseFunctions; + onDocumentDeletedWithAuthContext: FirebaseFunctions; + onDocumentWrittenWithAuthContext: FirebaseFunctions; + onSchedule: FirebaseFunctions; + onObjectFinalized: FirebaseFunctions; + onObjectArchived: FirebaseFunctions; + onObjectDeleted: FirebaseFunctions; + onObjectMetadataUpdated: FirebaseFunctions; +}; From 027ab90eb7a92c2a4f155dbccf6038a4163966fd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 14:33:47 +0200 Subject: [PATCH 11/19] fix(nextjs): Remove usage of chalk to avoid runtime errors (#18010) This is currently a bandaid fix to remove the `chalk` import statements that break turbopack apps during runtime until we have more details how this code gets bundled into production. ref https://github.com/getsentry/sentry-javascript/pull/17806 closes https://github.com/getsentry/sentry-javascript/issues/17691 --- packages/nextjs/package.json | 1 - .../src/config/loaders/wrappingLoader.ts | 5 +--- packages/nextjs/src/config/webpack.ts | 25 ++++--------------- yarn.lock | 16 ++++++------ 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 40924e8abd31..26b3a172090a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,7 +87,6 @@ "@sentry/react": "10.21.0", "@sentry/vercel-edge": "10.21.0", "@sentry/webpack-plugin": "^4.3.0", - "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index c60563ccd241..3125102e9656 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -1,6 +1,5 @@ import commonjs from '@rollup/plugin-commonjs'; import { stringMatchesSomePattern } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import type { RollupBuild, RollupError } from 'rollup'; @@ -165,9 +164,7 @@ export default function wrappingLoader( if (!showedMissingAsyncStorageModuleWarning) { // eslint-disable-next-line no-console console.warn( - `${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan( - 'RequestAsyncStorage', - )} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`, + "[@sentry/nextjs] The Sentry SDK could not access the 'RequestAsyncStorage' module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.", ); showedMissingAsyncStorageModuleWarning = true; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 14f064ae2b0a..4484b1194bd2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -2,7 +2,6 @@ /* eslint-disable max-lines */ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; @@ -245,11 +244,7 @@ export function constructWebpackConfigFunction({ vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons; if (vercelCronsConfig) { debug.log( - `${chalk.cyan( - 'info', - )} - Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the ${chalk.bold.cyan( - 'automaticVercelMonitors', - )} option to false in you Next.js config.`, + "[@sentry/nextjs] Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the 'automaticVercelMonitors' option to false in you Next.js config.", ); } } @@ -259,9 +254,7 @@ export function constructWebpackConfigFunction({ } else { // log but noop debug.error( - `${chalk.red( - 'error', - )} - Sentry failed to read vercel.json for automatic cron job monitoring instrumentation`, + '[@sentry/nextjs] Failed to read vercel.json for automatic cron job monitoring instrumentation', e, ); } @@ -344,11 +337,7 @@ export function constructWebpackConfigFunction({ ) { // eslint-disable-next-line no-console console.log( - `${chalk.yellow( - 'warn', - )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( - 'global-error.js', - )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)`, + "[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)", ); showedMissingGlobalErrorWarningMsg = true; } @@ -541,9 +530,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', - ), + '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', ); } return; @@ -552,9 +539,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!instrumentationFile.includes('onRequestError')) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', - ), + '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', ); } } diff --git a/yarn.lock b/yarn.lock index 7a38d3f22cd3..06f8d3741128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12615,14 +12615,6 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4. escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@3.0.0, chalk@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" - integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -12642,6 +12634,14 @@ chalk@^1.0.0: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" From 7968cd85e321d743a6807459f4deedbfde5867b4 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:37:15 +0200 Subject: [PATCH 12/19] fix(react): Don't trim index route `/` when getting pathname (#17985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, the test in this PR fail: https://github.com/getsentry/sentry-javascript/pull/17789 Root routes can yield an empty transaction name, causing `` instead of `/` as the transaction name for the root. This happens when the router includes children routes with `index: true`. The route matching is also depending on the `allRoutes` Set. The `allRoutes` Set include the children routes twice (once as children of the route and once as a root element of the Set). When only including them once, it works but parametrization does not work anymore. --> First I thought, this duplication is the cause but probably it isn't ## What’s broken Root cause is in `sendIndexPath(...)`: - Mis-parenthesized ternary picks `stripBasenameFromPathname` instead of `pathBuilder`. - Trimming turns `/` into an empty string. --- .../react-router-7-cross-usage/src/index.tsx | 8 + .../instrumentation.tsx | 6 +- .../src/reactrouter-compat-utils/utils.ts | 28 ++- .../instrumentation.test.tsx | 192 ++++++++++++++++++ .../reactrouter-compat-utils/utils.test.ts | 2 +- 5 files changed, 222 insertions(+), 14 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index bfcc527ded1b..089b27ab974a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -89,6 +89,14 @@ const ProjectsRoutes = () => ( ); const router = sentryCreateBrowserRouter([ + { + path: '/post/:post', + element:
Post
, + children: [ + { index: true, element:
Post Index
}, + { path: '/post/:post/related', element:
Related Posts
}, + ], + }, { children: [ { diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index bf57fdbd74dc..a72c4fd05378 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -106,7 +106,8 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; // Keeping as a global variable for cross-usage in multiple functions -const allRoutes = new Set(); +// only exported for testing purposes +export const allRoutes = new Set(); /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -679,7 +680,8 @@ export function handleNavigation(opts: { } } -function addRoutesToAllRoutes(routes: RouteObject[]): void { +/* Only exported for testing purposes */ +export function addRoutesToAllRoutes(routes: RouteObject[]): void { routes.forEach(route => { const extractedChildRoutes = getChildRoutesRecursively(route); diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index c0750c17c57c..d6501d0e4dbf 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -45,21 +45,27 @@ export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch within ) */ +export function routeIsDescendant(route: RouteObject): boolean { return !!(!route.children && route.element && route.path?.endsWith('/*')); } function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { - const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; - - const formattedPath = - // If the path ends with a slash, remove it - reconstructedPath[reconstructedPath.length - 1] === '/' - ? reconstructedPath.slice(0, -1) - : // If the path ends with a wildcard, remove it - reconstructedPath.slice(-2) === '/*' - ? reconstructedPath.slice(0, -1) - : reconstructedPath; + const reconstructedPath = + pathBuilder && pathBuilder.length > 0 + ? pathBuilder + : _stripBasename + ? stripBasenameFromPathname(pathname, basename) + : pathname; + + let formattedPath = + // If the path ends with a wildcard suffix, remove both the slash and the asterisk + reconstructedPath.slice(-2) === '/*' ? reconstructedPath.slice(0, -2) : reconstructedPath; + + // If the path ends with a slash, remove it (but keep single '/') + if (formattedPath.length > 1 && formattedPath[formattedPath.length - 1] === '/') { + formattedPath = formattedPath.slice(0, -1); + } return [formattedPath, 'route']; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 0eeeeb342287..4785849f1192 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -10,6 +10,7 @@ import { createReactRouterV6CompatibleTracingIntegration, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; +import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -47,6 +48,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ initializeRouterUtils: vi.fn(), getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), + routeIsDescendant: vi.fn(() => false), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -141,3 +143,193 @@ describe('reactrouter-compat-utils/instrumentation', () => { }); }); }); + +describe('addRoutesToAllRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + allRoutes.clear(); + }); + + it('should add simple routes without nesting', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toHaveLength(3); + expect(allRoutesArr).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/' }), + expect.objectContaining({ path: '/user/:id' }), + expect.objectContaining({ path: '/group/:group/:user?' }), + ]), + ); + + // Verify exact structure matches manual testing results + allRoutesArr.forEach(route => { + expect(route).toHaveProperty('element'); + expect(route.element).toHaveProperty('props'); + }); + }); + + it('should handle complex nested routes with multiple levels', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + // v1 routes ---- + { + path: '/v1/post/:post', + element:
, + children: [ + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + ], + }, + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + { element:
Edit Post
, path: 'edit' }, + // v2 routes --- + { + path: '/v2/post/:post', + element: expect.objectContaining({ type: 'div', props: {} }), + children: [ + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ], + }, + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ]); + }); + + it('should handle routes with nested index routes', () => { + const routes = [ + { + path: '/dashboard', + element:
, + children: [ + { index: true, element:
Dashboard Index
}, + { path: 'settings', element:
Settings
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/dashboard', + element: expect.objectContaining({ type: 'div' }), + children: [ + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ], + }, + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ]); + }); + + it('should handle deeply nested routes with layout wrappers', () => { + const routes = [ + { + path: '/', + element:
Root
, + children: [ + { path: 'dashboard', element:
Dashboard
}, + { + element:
AuthLayout
, + children: [{ path: 'login', element:
Login
}], + }, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/', + element: expect.objectContaining({ type: 'div', props: { children: 'Root' } }), + children: [ + { + path: 'dashboard', + element: expect.objectContaining({ type: 'div', props: { children: 'Dashboard' } }), + }, + { + element: expect.objectContaining({ type: 'div', props: { children: 'AuthLayout' } }), + children: [ + { + path: 'login', + element: expect.objectContaining({ type: 'div', props: { children: 'Login' } }), + }, + ], + }, + ], + }, + { element:
Dashboard
, path: 'dashboard' }, + { + children: [{ element:
Login
, path: 'login' }], + element:
AuthLayout
, + }, + { element:
Login
, path: 'login' }, + ]); + }); + + it('should not duplicate routes when called multiple times', () => { + const routes = [ + { path: '/', element:
}, + { path: '/about', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const firstCount = allRoutes.size; + + addRoutesToAllRoutes(routes); + const secondCount = allRoutes.size; + + expect(firstCount).toBe(secondCount); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 91885940db31..9ff48e7450bc 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -436,7 +436,7 @@ describe('reactrouter-compat-utils/utils', () => { ]; const result = getNormalizedName(routes, location, branches, ''); - expect(result).toEqual(['', 'route']); + expect(result).toEqual(['/', 'route']); }); it('should handle simple route path', () => { From 925a4ea55434574a261f2f46b0620b4ba4fcd6fd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 23 Oct 2025 16:04:38 +0200 Subject: [PATCH 13/19] feat(cloudflare,vercel-edge): Add support for LangChain instrumentation (#17986) Adds support for LangChain manual instrumentation in @sentry/cloudflare and @sentry/vercel-edge. To instrument LangChain operations, create a callback handler with Sentry.createLangChainCallbackHandler and pass it to your LangChain invocations. ``` import * as Sentry from '@sentry/cloudflare'; import { ChatAnthropic } from '@langchain/anthropic'; // Create a LangChain callback handler const callbackHandler = Sentry.createLangChainCallbackHandler({ recordInputs: true, // Optional: record input prompts/messages recordOutputs: true // Optional: record output responses }); // Use with chat models const model = new ChatAnthropic({ model: 'claude-3-5-sonnet-20241022', apiKey: 'your-api-key' }); await model.invoke('Tell me a joke', { callbacks: [callbackHandler] }); ``` The callback handler automatically creates spans for: - Chat model invocations (gen_ai.chat) - LLM invocations (gen_ai.pipeline) - Chain executions (gen_ai.invoke_agent) - Tool executions (gen_ai.execute_tool) --- .../suites/tracing/langchain/index.ts | 50 +++++ .../suites/tracing/langchain/mocks.ts | 197 ++++++++++++++++++ .../suites/tracing/langchain/test.ts | 64 ++++++ .../suites/tracing/langchain/wrangler.jsonc | 6 + packages/cloudflare/src/index.ts | 1 + packages/vercel-edge/src/index.ts | 1 + 6 files changed, 319 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts new file mode 100644 index 000000000000..0d59fd91c2b7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/cloudflare'; +import { MockChain, MockChatModel, MockTool } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + // Create LangChain callback handler + const callbackHandler = Sentry.createLangChainCallbackHandler({ + recordInputs: false, + recordOutputs: false, + }); + + // Test 1: Chat model invocation + const chatModel = new MockChatModel({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + }); + + await chatModel.invoke('Tell me a joke', { + callbacks: [callbackHandler], + }); + + // Test 2: Chain invocation + const chain = new MockChain('my_test_chain'); + await chain.invoke( + { input: 'test input' }, + { + callbacks: [callbackHandler], + }, + ); + + // Test 3: Tool invocation + const tool = new MockTool('search_tool'); + await tool.call('search query', { + callbacks: [callbackHandler], + }); + + return new Response(JSON.stringify({ success: true })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts new file mode 100644 index 000000000000..946ae8252dbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts @@ -0,0 +1,197 @@ +// Mock LangChain types and classes for testing the callback handler + +// Minimal callback handler interface to match LangChain's callback handler signature +export interface CallbackHandler { + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[] | Record, + metadata?: Record, + runName?: string, + ) => unknown; + handleLLMEnd?: (output: unknown, runId: string) => unknown; + handleChainStart?: (chain: { name?: string }, inputs: Record, runId: string) => unknown; + handleChainEnd?: (outputs: unknown, runId: string) => unknown; + handleToolStart?: (tool: { name?: string }, input: string, runId: string) => unknown; + handleToolEnd?: (output: unknown, runId: string) => unknown; +} + +export interface LangChainMessage { + role: string; + content: string; +} + +export interface LangChainLLMResult { + generations: Array< + Array<{ + text: string; + generationInfo?: Record; + }> + >; + llmOutput?: { + tokenUsage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + }; +} + +export interface InvocationParams { + model: string; + temperature?: number; + maxTokens?: number; +} + +// Mock LangChain Chat Model +export class MockChatModel { + private _model: string; + private _temperature?: number; + private _maxTokens?: number; + + public constructor(params: InvocationParams) { + this._model = params.model; + this._temperature = params.temperature; + this._maxTokens = params.maxTokens; + } + + public async invoke( + messages: LangChainMessage[] | string, + options?: { callbacks?: CallbackHandler[] }, + ): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Get invocation params to match LangChain's signature + const invocationParams = { + model: this._model, + temperature: this._temperature, + max_tokens: this._maxTokens, + }; + + // Create serialized representation similar to LangChain + const serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'anthropic', 'anthropic'], // Third element is used as system provider + kwargs: invocationParams, + }; + + // Call handleChatModelStart + // Pass tags as a record with invocation_params for proper extraction + // The callback handler's getInvocationParams utility accepts both string[] and Record + for (const callback of callbacks) { + if (callback.handleChatModelStart) { + await callback.handleChatModelStart( + serialized, + messages, + runId, + undefined, + undefined, + { invocation_params: invocationParams }, + { ls_model_name: this._model, ls_provider: 'anthropic' }, + ); + } + } + + // Create mock result + const result: LangChainLLMResult = { + generations: [ + [ + { + text: 'Mock response from LangChain!', + generationInfo: { + finish_reason: 'stop', + }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + }, + }; + + // Call handleLLMEnd + for (const callback of callbacks) { + if (callback.handleLLMEnd) { + await callback.handleLLMEnd(result, runId); + } + } + + return result; + } +} + +// Mock LangChain Chain +export class MockChain { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async invoke( + inputs: Record, + options?: { callbacks?: CallbackHandler[] }, + ): Promise> { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleChainStart + for (const callback of callbacks) { + if (callback.handleChainStart) { + await callback.handleChainStart({ name: this._name }, inputs, runId); + } + } + + const outputs = { result: 'Chain execution completed!' }; + + // Call handleChainEnd + for (const callback of callbacks) { + if (callback.handleChainEnd) { + await callback.handleChainEnd(outputs, runId); + } + } + + return outputs; + } +} + +// Mock LangChain Tool +export class MockTool { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async call(input: string, options?: { callbacks?: CallbackHandler[] }): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleToolStart + for (const callback of callbacks) { + if (callback.handleToolStart) { + await callback.handleToolStart({ name: this._name }, input, runId); + } + } + + const output = `Tool ${this._name} executed with input: ${input}`; + + // Call handleToolEnd + for (const callback of callbacks) { + if (callback.handleToolEnd) { + await callback.handleToolEnd(output, runId); + } + } + + return output; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..875b4191b84b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not break in our +// cloudflare SDK. + +it('traces langchain chat model, chain, and tool invocations', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // Chat model span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + }), + // Chain span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.invoke_agent', + 'langchain.chain.name': 'my_test_chain', + }), + description: 'chain my_test_chain', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langchain', + }), + // Tool span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.execute_tool', + 'gen_ai.tool.name': 'search_tool', + }), + description: 'execute_tool search_tool', + op: 'gen_ai.execute_tool', + origin: 'auto.ai.langchain', + }), + ]), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 6f731cb8d980..a6aa7ffc8d9a 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -96,6 +96,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, growthbookIntegration, logger, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d8362ff31c98..7a73234f535e 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -95,6 +95,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, logger, } from '@sentry/core'; From 7d050b545d728a301bf4b239fbd6ff731cea4cae Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:07:07 +0200 Subject: [PATCH 14/19] fix(core): Fix wrong async types when instrumenting anthropic's stream api (#18007) The issue surfaced when `message.stream` was used in conjunction with the `stream: true` option which would lead to us returning async results instead of the expected MessageStream from anthropic ai. We now take this into account and tightened the types. Closes: #17977 --- .../tracing/anthropic/scenario-stream.mjs | 78 ++++++++++++++++++- .../suites/tracing/anthropic/test.ts | 25 ++++++ packages/core/src/utils/anthropic-ai/index.ts | 21 ++--- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs index ac5eb6019010..4e0fa74fdd0d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs @@ -32,6 +32,62 @@ function createMockStreamEvents(model = 'claude-3-haiku-20240307') { return generator(); } +// Mimics Anthropic SDK's MessageStream class +class MockMessageStream { + constructor(model) { + this._model = model; + this._eventHandlers = {}; + } + + on(event, handler) { + if (!this._eventHandlers[event]) { + this._eventHandlers[event] = []; + } + this._eventHandlers[event].push(handler); + + // Start processing events asynchronously (don't await) + if (event === 'streamEvent' && !this._processing) { + this._processing = true; + this._processEvents(); + } + + return this; + } + + async _processEvents() { + try { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + if (this._eventHandlers['streamEvent']) { + for (const handler of this._eventHandlers['streamEvent']) { + handler(event); + } + } + } + + // Emit 'message' event when done + if (this._eventHandlers['message']) { + for (const handler of this._eventHandlers['message']) { + handler(); + } + } + } catch (error) { + if (this._eventHandlers['error']) { + for (const handler of this._eventHandlers['error']) { + handler(error); + } + } + } + } + + async *[Symbol.asyncIterator]() { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + yield event; + } + } +} + class MockAnthropic { constructor(config) { this.apiKey = config.apiKey; @@ -68,9 +124,9 @@ class MockAnthropic { }; } - async _messagesStream(params) { - await new Promise(resolve => setTimeout(resolve, 5)); - return createMockStreamEvents(params?.model); + // This should return synchronously (like the real Anthropic SDK) + _messagesStream(params) { + return new MockMessageStream(params?.model); } } @@ -90,13 +146,27 @@ async function run() { } // 2) Streaming via messages.stream API - const stream2 = await client.messages.stream({ + const stream2 = client.messages.stream({ model: 'claude-3-haiku-20240307', messages: [{ role: 'user', content: 'Stream this too' }], }); for await (const _ of stream2) { void _; } + + // 3) Streaming via messages.stream API with redundant stream: true param + const stream3 = client.messages.stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream with param' }], + stream: true, // This param is redundant but should not break synchronous behavior + }); + // Verify it has .on() method immediately (not a Promise) + if (typeof stream3.on !== 'function') { + throw new Error('BUG: messages.stream() with stream: true did not return MessageStream synchronously!'); + } + for await (const _ of stream3) { + void _; + } }); } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 57e788b721d1..2c92c6f8d233 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -308,6 +308,23 @@ describe('Anthropic integration', () => { 'gen_ai.usage.total_tokens': 25, }), }), + // messages.stream with redundant stream: true param + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.system': 'anthropic', + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + 'gen_ai.response.streaming': true, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_stream_1', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + }), ]), }; @@ -331,6 +348,14 @@ describe('Anthropic integration', () => { 'gen_ai.response.text': 'Hello from stream!', }), }), + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'Hello from stream!', + }), + }), ]), }; diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index d81741668be9..669d8a61b068 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -205,8 +205,8 @@ function handleStreamingError(error: unknown, span: Span, methodPath: string): n * Handle streaming cases with common logic */ function handleStreamingRequest( - originalMethod: (...args: T) => Promise, - target: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, + target: (...args: T) => R | Promise, context: unknown, args: T, requestAttributes: Record, @@ -215,7 +215,8 @@ function handleStreamingRequest( params: Record | undefined, options: AnthropicAiOptions, isStreamRequested: boolean, -): Promise { + isStreamingMethod: boolean, +): R | Promise { const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const spanConfig = { name: `${operationName} ${model} stream-response`, @@ -223,7 +224,8 @@ function handleStreamingRequest( attributes: requestAttributes as Record, }; - if (isStreamRequested) { + // messages.stream() always returns a sync MessageStream, even with stream: true param + if (isStreamRequested && !isStreamingMethod) { return startSpanManual(spanConfig, async span => { try { if (options.recordInputs && params) { @@ -260,13 +262,13 @@ function handleStreamingRequest( * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation */ function instrumentMethod( - originalMethod: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, methodPath: AnthropicAiInstrumentedMethod, context: unknown, options: AnthropicAiOptions, -): (...args: T) => Promise { +): (...args: T) => R | Promise { return new Proxy(originalMethod, { - apply(target, thisArg, args: T): Promise { + apply(target, thisArg, args: T): R | Promise { const requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); @@ -287,6 +289,7 @@ function instrumentMethod( params, options, isStreamRequested, + isStreamingMethod, ); } @@ -320,7 +323,7 @@ function instrumentMethod( }, ); }, - }) as (...args: T) => Promise; + }) as (...args: T) => R | Promise; } /** @@ -333,7 +336,7 @@ function createDeepProxy(target: T, currentPath = '', options: const methodPath = buildMethodPath(currentPath, String(prop)); if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + return instrumentMethod(value as (...args: unknown[]) => unknown | Promise, methodPath, obj, options); } if (typeof value === 'function') { From 1513161b12c33305fd5bdea6e1da57733fb65d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 16:18:09 +0200 Subject: [PATCH 15/19] chore: Add required size_check for GH Actions (#18009) It seems that the size limit was not enforced and a PR could be merged without the job being green: --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4066a18eefe2..46d6e7d4fac9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1168,6 +1168,7 @@ jobs: job_lint, job_check_format, job_circular_dep_check, + job_size_check, ] # Always run this, even if a dependent job failed if: always() From 0bac0ea0560a1f636d715215a1667c4cb0ab3158 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 16:20:26 +0200 Subject: [PATCH 16/19] test(react): Add parameterized route tests for `createHashRouter` (#17789) Co-authored-by: s1gr1d Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../react-create-hash-router/package.json | 3 +- .../react-create-hash-router/src/index.tsx | 26 ++ .../src/pages/Group.tsx | 7 + .../src/pages/Index.tsx | 18 ++ .../src/pages/Post.tsx | 13 + .../src/pages/PostFeatured.tsx | 7 + .../src/pages/PostIndex.tsx | 7 + .../src/pages/PostRelated.tsx | 7 + .../tests/transactions.test.ts | 258 +++++++++++++++++- 9 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index 3dea78b20080..afe9486eeebf 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -11,10 +11,11 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "~5.0.0" + "typescript": "~4.9.5" }, "scripts": { "build": "react-scripts build", + "dev": "react-scripts start", "start": "serve -s build", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 2ad9490ccd57..86de5f20378d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -11,6 +11,7 @@ import { } from 'react-router-dom'; import Index from './pages/Index'; import User from './pages/User'; +import Group from './pages/Group'; const replay = Sentry.replayIntegration(); @@ -52,6 +53,31 @@ const router = sentryCreateHashRouter([ path: '/user/:id', element: , }, + { + path: '/group/:group/:user?', + element: , + }, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, ]); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx new file mode 100644 index 000000000000..9dd9ac110898 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const Group = () => { + return

Group page

; +}; + +export default Group; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx index 1000dd53df27..20a2ab60fa21 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -15,6 +15,24 @@ const Index = () => { navigate + + Post 1 + + + Edit Post 1 + + + Post 1 featured + + + Post 1 related + + + Group 1 + + + Group 1 user 5 + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx new file mode 100644 index 000000000000..9b844b17ff68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Outlet } from 'react-router-dom'; + +const Post = () => { + return ( + <> +

Post V2 page

+ + + ); +}; + +export default Post; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx new file mode 100644 index 000000000000..0446fa0ec6ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostFeatured = () => { + return

Post featured page

; +}; + +export default PostFeatured; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx new file mode 100644 index 000000000000..ad3efaa9216a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostIndex = () => { + return

Post index page

; +}; + +export default PostIndex; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx new file mode 100644 index 000000000000..ff8d05f6f6f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostRelated = () => { + return

Post related page

; +}; + +export default PostRelated; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index 920506838080..36e6d0c18ee2 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -14,9 +14,9 @@ test('Captures a pageload transaction', async ({ page }) => { deviceMemory: expect.any(String), effectiveConnectionType: expect.any(String), hardwareConcurrency: expect.any(String), - 'lcp.element': 'body > div#root > input#exception-button[type="button"]', - 'lcp.id': 'exception-button', - 'lcp.size': 1650, + 'lcp.element': expect.any(String), + 'lcp.id': expect.any(String), + 'lcp.size': expect.any(Number), 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.react.reactrouter_v6', @@ -150,3 +150,255 @@ test('Captures a navigation transaction', async ({ page }) => { expect(transactionEvent.spans).toEqual([]); }); + +test('Captures a parameterized path pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/featured'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v1/post/1/edit'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/related'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-featured'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-edit'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-related'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1/5'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1-user-5'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); From 9c0397c7c10f5b9f902772c33742c3c37745e9cc Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:33:45 +0200 Subject: [PATCH 17/19] test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility (#18013) - Migrated from Jest `done` callback to [Promise-based async test pattern](https://vitest.dev/guide/migration.html#done-callback) - Fixed test assertion by adding missing `` tag to trigger `getTraceMetaTags` call (the test did not work before as `getTraceMetaTags` is only called when there is a closing `head` tag - Corrected stream piping logic to properly test transformer functionality The getMetaTagTransformer function internally pipes the transformer TO the bodyStream (`htmlMetaTagTransformer.pipe(body)`). So the correct flow is: 1. Write data to the transformer 2. Transformer processes it and pipes to bodyStream 3. Read the output from bodyStream --- .../test/server/getMetaTagTransformer.test.ts | 127 ++++++++++++++++++ .../test/server/getMetaTagTransformer.ts | 91 ------------- 2 files changed, 127 insertions(+), 91 deletions(-) create mode 100644 packages/react-router/test/server/getMetaTagTransformer.test.ts delete mode 100644 packages/react-router/test/server/getMetaTagTransformer.ts diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts new file mode 100644 index 000000000000..6900e1431ee7 --- /dev/null +++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts @@ -0,0 +1,127 @@ +import { getTraceMetaTags } from '@sentry/core'; +import { PassThrough } from 'stream'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; + +vi.mock('@opentelemetry/core', () => ({ + RPCType: { HTTP: 'http' }, + getRPCMetadata: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), +})); + +describe('getMetaTagTransformer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('should inject meta tags before closing head tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should not modify chunks without head closing tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toBe('Test'); + expect(outputData).not.toContain('sentry-trace'); + expect(getTraceMetaTags).not.toHaveBeenCalled(); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should handle buffer input', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(Buffer.from('Test')); + transformer.end(); + })); + + test('should handle multiple chunks', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).toContain('Test content'); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(''); + transformer.write('Test content'); + transformer.write(''); + transformer.end(); + })); +}); diff --git a/packages/react-router/test/server/getMetaTagTransformer.ts b/packages/react-router/test/server/getMetaTagTransformer.ts deleted file mode 100644 index 16334888627c..000000000000 --- a/packages/react-router/test/server/getMetaTagTransformer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getTraceMetaTags } from '@sentry/core'; -import { PassThrough } from 'stream'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; - -vi.mock('@opentelemetry/core', () => ({ - RPCType: { HTTP: 'http' }, - getRPCMetadata: vi.fn(), -})); - -vi.mock('@sentry/core', () => ({ - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - getTraceMetaTags: vi.fn(), -})); - -describe('getMetaTagTransformer', () => { - beforeEach(() => { - vi.clearAllMocks(); - (getTraceMetaTags as unknown as ReturnType).mockReturnValue( - '', - ); - }); - - test('should inject meta tags before closing head tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - expect(outputData).not.toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should not modify chunks without head closing tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toBe('Test'); - expect(getTraceMetaTags).toHaveBeenCalled(); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should handle buffer input', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write(Buffer.from('Test')); - bodyStream.end(); - }); -}); From fd265698ca5744942269f4f6b6cd18328e7063ab Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 23 Oct 2025 15:34:59 +0100 Subject: [PATCH 18/19] fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions (#17962) Fixes an issue where `pageload` and `navigation` transactions have incorrect (URL-based or wildcard-based) names when the span is cancelled early before lazy routes finish loading. This occurs when `document.hidden` triggers early span cancellation (e.g., user switches tabs during page load). In React Router applications with lazy routes, the parameterized route information may not be available yet when the span ends, resulting in transaction names like `/user/123/edit` (URL-based) or `/projects/*/views/*` (wildcard-based) instead of the correct parameterized route like `/user/:id/edit` or `/projects/:projectId/views/:viewId`. This fix patches `span.end()` to perform a final route resolution check before the span is sent, using the live global `allRoutes` Set to capture any lazy routes that loaded after the span was created but before it ended. --- .../tests/transactions.test.ts | 121 ++++++++++++++++++ .../instrumentation.tsx | 119 +++++++++++++---- .../instrumentation.test.tsx | 37 ++++++ 3 files changed, 254 insertions(+), 23 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 59d43c14ae95..3901b0938ca5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -377,3 +377,124 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', expect(backNavigationEvent.transaction).toBe('/'); expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); }); + +test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up the page to simulate document.hidden before navigation + await page.addInitScript(() => { + // Wait a bit for Sentry to initialize and start the pageload span + setTimeout(() => { + // Override document.hidden to simulate tab switching + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return true; + }, + }); + + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 100); // Small delay to ensure the span has started + }); + + // Navigate to the lazy route URL + await page.goto('/lazy/inner/1/2/3'); + + const event = await transactionPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('pageload'); + + // Check if the span was indeed cancelled (should have idle_span_finish_reason attribute) + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + if (idleSpanFinishReason) { + // If the span was cancelled due to visibility change, verify it still got the right name + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); + +test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + // First go to home page + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up a listener to simulate document.hidden after clicking the navigation link + await page.evaluate(() => { + // Override document.hidden to simulate tab switching + let hiddenValue = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return hiddenValue; + }, + }); + + // Listen for clicks on the navigation link and simulate document.hidden shortly after + document.addEventListener( + 'click', + () => { + setTimeout(() => { + hiddenValue = true; + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 50); // Small delay to ensure the navigation span has started + }, + { once: true }, + ); + }); + + // Click the navigation link to navigate to the lazy route + const navigationLink = page.locator('id=navigation'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await navigationPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('navigation'); + + // Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason) + const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason']; + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + + // Verify that the span was cancelled due to document.hidden + if (cancellationReason) { + expect(cancellationReason).toBe('document.hidden'); + } + + if (idleSpanFinishReason) { + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a72c4fd05378..a6e55f1a967c 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -8,7 +8,7 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; +import type { Client, Integration, Span } from '@sentry/core'; import { addNonEnumerableProperty, debug, @@ -41,14 +41,7 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { - getNormalizedName, - initializeRouterUtils, - locationIsInsideDescendantRoute, - prefixWithSlash, - rebuildRoutePathFromAllRoutes, - resolveRouteNameAndSource, -} from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -668,7 +661,7 @@ export function handleNavigation(opts: { // Cross usage can result in multiple navigation spans being created without this check if (!isAlreadyInNavigationSpan) { - startBrowserTracingNavigationSpan(client, { + const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, @@ -676,6 +669,11 @@ export function handleNavigation(opts: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, }, }); + + // Patch navigation span to handle early cancellation (e.g., document.hidden) + if (navigationSpan) { + patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); + } } } } @@ -729,29 +727,104 @@ function updatePageloadTransaction({ : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); if (branches) { - let name, - source: TransactionSource = 'url'; - - const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); - - if (isInDescendantRoute) { - name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location)); - source = 'route'; - } - - if (!isInDescendantRoute || !name) { - [name, source] = getNormalizedName(routes, location, branches, basename); - } + const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename); getCurrentScope().setTransactionName(name || '/'); if (activeRootSpan) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + + // Patch span.end() to ensure we update the name one last time before the span is sent + patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); } } } +/** + * Patches the span.end() method to update the transaction name one last time before the span is sent. + * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. + */ +function patchSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, + spanType: 'pageload' | 'navigation', +): void { + const patchedPropertyName = `__sentry_${spanType}_end_patched__` as const; + const hasEndBeenPatched = (span as unknown as Record)?.[patchedPropertyName]; + + if (hasEndBeenPatched || !span.end) { + return; + } + + const originalEnd = span.end.bind(span); + + span.end = function patchedEnd(...args) { + try { + // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) + const spanJson = spanToJSON(span); + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (currentSource !== 'route') { + // Last chance to update the transaction name with the latest route info + // Use the live global allRoutes Set to include any lazy routes loaded after patching + const currentAllRoutes = Array.from(allRoutes); + const branches = _matchRoutes( + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + location, + basename, + ) as unknown as RouteMatch[]; + + if (branches) { + const [name, source] = resolveRouteNameAndSource( + location, + routes, + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + branches, + basename, + ); + + // Only update if we have a valid name + if (name && (spanType === 'pageload' || !spanJson.timestamp)) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } + } + } catch (error) { + // Silently catch errors to ensure span.end() is always called + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } + + return originalEnd(...args); + }; + + // Mark this span as having its end() method patched to prevent duplicate patching + addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); +} + +function patchPageloadSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); +} + +function patchNavigationSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( Routes: R, diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 4785849f1192..bad264d3d6b5 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -142,6 +142,43 @@ describe('reactrouter-compat-utils/instrumentation', () => { expect(typeof integration.afterAllSetup).toBe('function'); }); }); + + describe('span.end() patching for early cancellation', () => { + it('should update transaction name when span.end() is called during cancellation', () => { + const mockEnd = vi.fn(); + let patchedEnd: ((...args: any[]) => any) | null = null; + + const updateNameMock = vi.fn(); + const setAttributeMock = vi.fn(); + + const testSpan = { + updateName: updateNameMock, + setAttribute: setAttributeMock, + get end() { + return patchedEnd || mockEnd; + }, + set end(fn: (...args: any[]) => any) { + patchedEnd = fn; + }, + } as unknown as Span; + + // Simulate the patching behavior + const originalEnd = testSpan.end.bind(testSpan); + (testSpan as any).end = function patchedEndFn(...args: any[]) { + // This simulates what happens in the actual implementation + updateNameMock('Updated Route'); + setAttributeMock('sentry.source', 'route'); + return originalEnd(...args); + }; + + // Call the patched end + testSpan.end(12345); + + expect(updateNameMock).toHaveBeenCalledWith('Updated Route'); + expect(setAttributeMock).toHaveBeenCalledWith('sentry.source', 'route'); + expect(mockEnd).toHaveBeenCalledWith(12345); + }); + }); }); describe('addRoutesToAllRoutes', () => { From 8debb05b5b8924220692a275810e2b9c53ea7cef Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 17:07:55 +0200 Subject: [PATCH 19/19] meta(changelog): Update changelog for 10.22.0 --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9b82b9bc8e..d91a753f6544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.22.0 + +### Important Changes + +- **feat(node): Instrument cloud functions for firebase v2 ([#17952](https://github.com/getsentry/sentry-javascript/pull/17952))** + + We added instrumentation for Cloud Functions for Firebase v2, enabling automatic performance tracking and error monitoring. This will be added automatically if you have enabled tracing. + +- **feat(core): Instrument LangChain AI ([#17955](https://github.com/getsentry/sentry-javascript/pull/17955))** + + Instrumentation was added for LangChain AI operations. You can configure what is recorded like this: + + ```ts + Sentry.init({ + integrations: [ + Sentry.langChainIntegration({ + recordInputs: true, // Record prompts/messages + recordOutputs: true, // Record responses + }), + ], + }); + ``` + +### Other Changes + +- feat(cloudflare,vercel-edge): Add support for LangChain instrumentation ([#17986](https://github.com/getsentry/sentry-javascript/pull/17986)) +- feat: Align sentry origin with documentation ([#17998](https://github.com/getsentry/sentry-javascript/pull/17998)) +- feat(core): Truncate request messages in AI integrations ([#17921](https://github.com/getsentry/sentry-javascript/pull/17921)) +- feat(nextjs): Support node runtime on proxy files ([#17995](https://github.com/getsentry/sentry-javascript/pull/17995)) +- feat(node): Pass requestHook and responseHook option to OTel ([#17996](https://github.com/getsentry/sentry-javascript/pull/17996)) +- fix(core): Fix wrong async types when instrumenting anthropic's stream api ([#18007](https://github.com/getsentry/sentry-javascript/pull/18007)) +- fix(nextjs): Remove usage of chalk to avoid runtime errors ([#18010](https://github.com/getsentry/sentry-javascript/pull/18010)) +- fix(node): Pino capture serialized `err` ([#17999](https://github.com/getsentry/sentry-javascript/pull/17999)) +- fix(node): Pino child loggers ([#17934](https://github.com/getsentry/sentry-javascript/pull/17934)) +- fix(react): Don't trim index route `/` when getting pathname ([#17985](https://github.com/getsentry/sentry-javascript/pull/17985)) +- fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions ([#17962](https://github.com/getsentry/sentry-javascript/pull/17962)) + +

+ Internal Changes + +- chore: Add required size_check for GH Actions ([#18009](https://github.com/getsentry/sentry-javascript/pull/18009)) +- chore: Upgrade madge to v8 ([#17957](https://github.com/getsentry/sentry-javascript/pull/17957)) +- test(hono): Fix hono e2e tests ([#18000](https://github.com/getsentry/sentry-javascript/pull/18000)) +- test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility ([#18013](https://github.com/getsentry/sentry-javascript/pull/18013)) +- test(react): Add parameterized route tests for `createHashRouter` ([#17789](https://github.com/getsentry/sentry-javascript/pull/17789)) + +
+ ## 10.21.0 ### Important Changes