diff --git a/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts b/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts new file mode 100644 index 000000000000..d0815f5237f3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts @@ -0,0 +1,67 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { consola } from 'consola'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + enableLogs: true, + transport: loggingTransport, +}); + +async function run(): Promise { + consola.level = 5; + + const sentryReporter = Sentry.createConsolaReporter(); + consola.addReporter(sentryReporter); + + // --- Fallback (first arg is string): message = formatted(all args), sentry.message.template + sentry.message.parameter.* --- + // Expected: message = "User logged in {...}", sentry.message.template = "User logged in {}", sentry.message.parameter.0 = { userId, sessionId } + consola.info('User logged in', { userId: 123, sessionId: 'abc-123' }); + + // Expected: message = formatted string, template + params for each following arg + consola.warn('Payment processed', { orderId: 456 }, { amount: 99.99, currency: 'USD' }); + + consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true }); + + consola.debug('Processing items', [1, 2, 3, 4, 5]); + + consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } }); + + consola.info('Deep object', { + level1: { + level2: { + level3: { + level4: { level5: 'should be normalized' }, + }, + }, + }, + simpleKey: 'simple value', + }); + + // --- Object-first (first arg is plain object): attributes from object, message = second arg if string, rest → sentry.message.parameter.* --- + // Expected: message = "User action", attributes userId: 789, sentry.message.parameter.0 = requestId, sentry.message.parameter.1 = timestamp + consola.info({ userId: 789 }, 'User action', 'req-123', 1234567890); + + // Expected: message = "", attributes from object only + consola.log({ event: 'click', buttonId: 'submit' }); + + // --- Consola-merged (consola.log({ message, ...rest })): Consola puts message in args[0] and spreads rest on logObj --- + // Expected: message = "inline-message", attributes userId, action, time (from logObj) + consola.log({ + message: 'inline-message', + userId: 123, + action: 'login', + time: new Date(), + }); + + // Fallback "Legacy log" style: first arg string, rest as params + // Expected: message = "Legacy log {...} 123", sentry.message.template = "Legacy log {} {}", sentry.message.parameter.0 = { data: 1 }, .1 = 123 + consola.log('Legacy log', { data: 1 }, 123); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts b/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts new file mode 100644 index 000000000000..e53006863b16 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts @@ -0,0 +1,44 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { consola } from 'consola'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + enableLogs: true, + transport: loggingTransport, +}); + +async function run(): Promise { + consola.level = 5; + + const sentryReporter = Sentry.createConsolaReporter(); + consola.addReporter(sentryReporter); + + // Test Date objects are preserved + consola.info('Current time:', new Date('2023-01-01T00:00:00.000Z')); + + // Test Error objects are preserved + consola.error('Error occurred:', new Error('Test error')); + + // Test RegExp objects are preserved + consola.info('Pattern:', /test/gi); + + // Test Map and Set objects are preserved + consola.info('Collections:', new Map([['key', 'value']]), new Set([1, 2, 3])); + + // Test mixed: nested object, primitives, Date, and Map + consola.info( + 'Mixed data', + { userId: 123, nestedMetadata: { id: 789, name: 'Jane', source: 'api' } }, + new Date('2023-06-15T12:00:00.000Z'), + 'a-simple-string', + new Map([['key', 'value']]), + ); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index 2ee47a17dd20..59031ed74330 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -279,7 +279,7 @@ describe('consola integration', () => { { timestamp: expect.any(Number), level: 'info', - body: 'Message with args: hello 123 {"key":"value"} [1,2,3]', + body: 'Message with args: hello 123', severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { @@ -291,6 +291,7 @@ describe('consola integration', () => { 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, 'consola.level': { value: 3, type: 'integer' }, + key: { value: 'value', type: 'string' }, }, }, { @@ -491,4 +492,247 @@ describe('consola integration', () => { await runner.completed(); }); + + test('should extract objects as searchable context attributes', async () => { + const runner = createRunner(__dirname, 'subject-object-context.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'User logged in', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + userId: { value: 123, type: 'integer' }, + sessionId: { value: 'abc-123', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'warn', + body: 'Payment processed', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'warn', type: 'string' }, + 'consola.level': { value: 1, type: 'integer' }, + orderId: { value: 456, type: 'integer' }, + amount: { value: 99.99, type: 'double' }, + currency: { value: 'USD', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Error occurred in payment module', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'error', type: 'string' }, + 'consola.level': { value: 0, type: 'integer' }, + errorCode: { value: 'E001', type: 'string' }, + retryable: { value: true, type: 'boolean' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'debug', + body: 'Processing items', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'debug', type: 'string' }, + 'consola.level': { value: 4, type: 'integer' }, + 'consola.args.0': { value: '[1,2,3,4,5]', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Complex data', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + user: { value: '{"id":789,"name":"Jane"}', type: 'string' }, + metadata: { value: '{"source":"api"}', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Deep object', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + // Nested objects are extracted and normalized respecting normalizeDepth setting + level1: { value: '{"level2":{"level3":{"level4":"[Object]"}}}', type: 'string' }, + simpleKey: { value: 'simple value', type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); + + test('should preserve special objects (Date, Error, RegExp, Map, Set) as context attributes', async () => { + const runner = createRunner(__dirname, 'subject-special-objects.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Current time:', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + // Date objects serialize with extra quotes + 'consola.args.0': { value: '"2023-01-01T00:00:00.000Z"', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Error occurred:', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'error', type: 'string' }, + 'consola.level': { value: 0, type: 'integer' }, + // Error objects serialize as empty object (properties are non-enumerable) + 'consola.args.0': { value: '{}', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Pattern:', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + // RegExp objects serialize as empty object + 'consola.args.0': { value: '{}', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Collections:', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + // Map converted to object, Set converted to array + 'consola.args.0': { value: '{"key":"value"}', type: 'string' }, + 'consola.args.1': { value: '[1,2,3]', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Mixed data a-simple-string', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + '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' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + // Plain object properties extracted + userId: { value: 123, type: 'integer' }, + // Nested metadata object normalized (depth 3) + nestedMetadata: { value: '{"id":789,"name":"Jane","source":"api"}', type: 'string' }, + // Date preserved as args + 'consola.args.0': { value: '"2023-06-15T12:00:00.000Z"', type: 'string' }, + // Map converted to object and stored as args + 'consola.args.1': { value: '{"key":"value"}', type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); }); diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 4781b253b161..e81345d479ce 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -1,8 +1,30 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { _INTERNAL_captureLog } from '../logs/internal'; -import { formatConsoleArgs } from '../logs/utils'; +import { createConsoleTemplateAttributes, formatConsoleArgs, hasConsoleSubstitutions } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; +import { isPlainObject } from '../utils/is'; +import { normalize } from '../utils/normalize'; + +/** + * Result of extracting structured attributes from console arguments. + */ +export interface ExtractAttributesResult { + /** + * Extracted attributes to add to the log. + */ + attributes?: Record; + + /** + * The message to use (if determined). + */ + message?: string; + + /** + * Remaining arguments to process as parameters. + */ + remainingArgs?: unknown[]; +} /** * Options for the Sentry Consola reporter. @@ -37,6 +59,36 @@ interface ConsolaReporterOptions { * ``` */ client?: Client; + + /** + * Custom function to extract structured attributes from console arguments. + * + * Return null/undefined to use default behavior, which extracts attributes + * when the first argument is a plain object. + * + * @param args - The raw arguments from consola + * @returns Extraction result or null for default behavior + * + * @example + * ```ts + * const sentryReporter = Sentry.createConsolaReporter({ + * extractAttributes: (args) => { + * // Custom logic to determine attributes + * if (args[0]?.type === 'structured') { + * return { + * attributes: args[0], + * message: args[1], + * remainingArgs: args.slice(2) + * }; + * } + * return null; // Use default behavior + * } + * }); + * ``` + */ + // `any` is the type that consola provides for log arguments + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extractAttributes?: (args: any[]) => ExtractAttributesResult | null | undefined; } export interface ConsolaReporter { @@ -61,7 +113,7 @@ export interface ConsolaReporter { */ export interface ConsolaLogObject { /** - * Allows additional custom properties to be set on the log object. + * Allows additional custom properties to be set on the log object when reporter is called directly. * These properties will be captured as log attributes with a 'consola.' prefix. * * @example @@ -145,12 +197,140 @@ export interface ConsolaLogObject { * * When provided, this is the final formatted message. When not provided, * the message should be constructed from the `args` array. + * + * Note: In reporters, `message` is typically undefined. It is primarily for + * `consola.[type]({ message: 'xxx' })` usage and is normalized into `args` before + * reporters receive the log object. See: https://github.com/unjs/consola/issues/406#issuecomment-3684792551 */ message?: string; } const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; +/** + * Extracts structured attributes from args if first arg is a plain object. + * + * @param args - The console arguments + * @param normalizeDepth - The depth to normalize the values + * @param normalizeMaxBreadth - The max breadth to normalize the values + * @returns Extraction result with attributes, message, and remaining args, or null for fallback behavior + */ +function extractStructuredAttributes( + args: unknown[] | undefined, + normalizeDepth: number, + normalizeMaxBreadth: number, +): ExtractAttributesResult | null { + if (!args || args.length === 0) { + return null; + } + + const firstArg = args[0]; + + // Check if first arg is a plain object + if (!isPlainObject(firstArg)) { + return null; // Fallback to legacy behavior + } + + // Extract attributes from first arg + const attributes = normalize(firstArg, normalizeDepth, normalizeMaxBreadth) as Record; + + // Determine message (second arg if 'string', otherwise empty) + const secondArg = args[1]; + const message = typeof secondArg === 'string' ? secondArg : ''; + + // Remaining args start from index 2 if we used second arg as message, otherwise from index 1 + const remainingArgsStartIndex = typeof secondArg === 'string' ? 2 : 1; + const remainingArgs = args.slice(remainingArgsStartIndex); + + return { + attributes, + message, + remainingArgs, + }; +} + +/** + * Processes args in fallback mode (same as console integration): formatted message plus template and parameters. + * Does not extract objects from args as attributes. + * + * @param args - The console arguments + * @param consolaMessage - The message from consola (used when args is empty) + * @param normalizeDepth - The depth to normalize the values + * @param normalizeMaxBreadth - The max breadth to normalize the values + * @returns Object containing the message and message attributes (template + parameters) + */ +function processArgsFallbackMode( + args: unknown[] | undefined, + consolaMessage: string | undefined, + normalizeDepth: number, + normalizeMaxBreadth: number, +): { message: string; messageAttributes: Record } { + const messageAttributes: Record = {}; + + if (!args?.length) { + return { message: consolaMessage || '', messageAttributes }; + } + + const message = formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth); + + const firstArg = args[0]; + const followingArgs = args.slice(1); + + if (followingArgs.length > 0 && typeof firstArg === 'string' && !hasConsoleSubstitutions(firstArg)) { + const templateAttrs = createConsoleTemplateAttributes(firstArg, followingArgs); + for (const [key, value] of Object.entries(templateAttrs)) { + messageAttributes[key] = key.startsWith('sentry.message.parameter.') + ? normalize(value, normalizeDepth, normalizeMaxBreadth) + : value; + } + } + + return { message, messageAttributes }; +} + +/** + * Processes structured extraction result and builds message and attributes. + * + * @param extractionResult - The result from extraction + * @param consolaMessage - The message from consola + * @param attributes - The attributes object to add extracted properties to + * @param normalizeDepth - The depth to normalize the values + * @param normalizeMaxBreadth - The max breadth to normalize the values + * @returns Object containing the message and message attributes + */ +function processStructuredMode( + extractionResult: ExtractAttributesResult, + consolaMessage: string | undefined, + attributes: Record, + normalizeDepth: number, + normalizeMaxBreadth: number, +): { message: string; messageAttributes: Record } { + const { attributes: extractedAttrs, message: extractedMsg, remainingArgs } = extractionResult; + const messageAttributes: Record = {}; + + // Use extracted message or consolaMessage + const message = extractedMsg || consolaMessage || ''; + + // Add extracted attributes, but don't override existing or consola-prefixed attributes + if (extractedAttrs) { + for (const [key, value] of Object.entries(extractedAttrs)) { + // Only add if not conflicting with existing or consola-prefixed attributes + if (!(key in attributes) && !(`consola.${key}` in attributes)) { + attributes[key] = value; + } + } + } + + // Add remaining args as parameters + if (remainingArgs && remainingArgs.length > 0) { + remainingArgs.forEach((arg, index) => { + messageAttributes[`sentry.message.parameter.${index}`] = normalize(arg, normalizeDepth, normalizeMaxBreadth); + }); + } + + return { message, messageAttributes }; +} + /** * Creates a new Sentry reporter for Consola that forwards logs to Sentry. Requires the `enableLogs` option to be enabled. * @@ -186,9 +366,18 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const providedClient = options.client; return { + // eslint-disable-next-line complexity log(logObj: ConsolaLogObject) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, level, message: consolaMessage, args, tag, date: _date, ...attributes } = logObj; + const { type, level, message: consolaMessage, args, tag, ...rest } = logObj; + + // Extra keys on logObj (beyond reserved) indicate direct `reporter.log({ type, message, ...rest })` + const hasExtraKeys = Object.keys(rest).length > 0; + + // Build attributes: custom properties from logObj get a consola. prefix; base attributes added below may override + const attributes: Record = {}; + for (const [key, value] of Object.entries(rest)) { + attributes[`consola.${key}`] = value; + } // Get client - use provided client or current client const client = providedClient || getClient(); @@ -206,17 +395,6 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); - // Format the log message using the same approach as consola's basic reporter - const messageParts = []; - if (consolaMessage) { - messageParts.push(consolaMessage); - } - if (args && args.length > 0) { - messageParts.push(formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth)); - } - const message = messageParts.join(' '); - - // Build attributes attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { @@ -232,6 +410,49 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } + // Direct Reporter Log: E.g. reporter.log({ message: "inline-message", userId, action }) + if (hasExtraKeys && args && args.length >= 1 && typeof args[0] === 'string') { + const message = args[0]; + _INTERNAL_captureLog({ + level: logSeverityLevel, + message, + attributes, + }); + return; + } + + // Try custom extraction first + let extractionResult: ExtractAttributesResult | null = null; + if (options.extractAttributes && args) { + extractionResult = options.extractAttributes(args) || null; + } + + // Object-first: first arg is plain object + if (!extractionResult && args && args.length > 0 && isPlainObject(args[0])) { + extractionResult = extractStructuredAttributes(args, normalizeDepth, normalizeMaxBreadth); + } + + let message: string; + let messageAttributes: Record = {}; + + if (extractionResult) { + const result = processStructuredMode( + extractionResult, + consolaMessage, + attributes, + normalizeDepth, + normalizeMaxBreadth, + ); + message = result.message; + messageAttributes = result.messageAttributes; + } else { + const fallback = processArgsFallbackMode(args, consolaMessage, normalizeDepth, normalizeMaxBreadth); + message = fallback.message; + messageAttributes = fallback.messageAttributes; + } + + Object.assign(attributes, messageAttributes); + _INTERNAL_captureLog({ level: logSeverityLevel, message, diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index a32f073eeb75..3c0fe493239b 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -5,15 +5,19 @@ import { _INTERNAL_captureLog } from '../../../src/logs/internal'; import { formatConsoleArgs } from '../../../src/logs/utils'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; -// Mock dependencies vi.mock('../../../src/logs/internal', () => ({ _INTERNAL_captureLog: vi.fn(), _INTERNAL_flushLogsBuffer: vi.fn(), })); -vi.mock('../../../src/logs/utils', async actual => ({ - formatConsoleArgs: vi.fn(((await actual()) as any).formatConsoleArgs), -})); +vi.mock('../../../src/logs/utils', async importOriginal => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual: typeof import('../../../src/logs/utils') = await importOriginal(); + return { + ...actual, + formatConsoleArgs: vi.fn(actual.formatConsoleArgs), + }; +}); vi.mock('../../../src/currentScopes', () => ({ getClient: vi.fn(), @@ -22,289 +26,488 @@ vi.mock('../../../src/currentScopes', () => ({ describe('createConsolaReporter', () => { let mockClient: TestClient; + let sentryReporter: ReturnType; beforeEach(() => { vi.clearAllMocks(); - - // Create a test client with enableLogs: true mockClient = new TestClient({ ...getDefaultTestClientOptions({ dsn: 'https://username@domain/123' }), enableLogs: true, normalizeDepth: 3, normalizeMaxBreadth: 1000, }); - - const mockScope = { - getClient: vi.fn().mockReturnValue(mockClient), - }; - vi.mocked(getClient).mockReturnValue(mockClient); - vi.mocked(getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(getCurrentScope).mockReturnValue({ + getClient: vi.fn().mockReturnValue(mockClient), + } as any); + sentryReporter = createConsolaReporter(); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('reporter creation', () => { - it('should create a reporter with log function', () => { - const reporter = createConsolaReporter(); + it('creates a reporter with a log function', () => { + const reporter = createConsolaReporter(); + expect(reporter).toEqual({ log: expect.any(Function) }); + }); - expect(reporter).toEqual({ - log: expect.any(Function), + /** + * Real-world Consola reporter payload shapes. + * LogObj structures match what Consola passes to reporters (consola merges + * consola.log({ message, ...rest }) into args: [message] + rest on logObj; + * consola.log.raw() and multi-arg calls pass raw args). + */ + describe('real-world consola payloads', () => { + it('consola-merged: args=[message] with extra keys on logObj', () => { + sentryReporter.log({ + type: 'log', + level: 2, + args: ['obj-message'], + userId: 123, + action: 'login', + time: '2026-02-24T10:24:04.477Z', + smallObj: { word: 'hi' }, + tag: '', }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('obj-message'); + expect(call.attributes).toMatchObject({ + 'consola.userId': 123, + 'consola.action': 'login', + 'consola.time': '2026-02-24T10:24:04.477Z', + 'consola.smallObj': { word: 'hi' }, + }); + expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); }); - }); - describe('log capturing', () => { - let sentryReporter: any; + it('direct reporter.log({ type, message, userId, sessionId }) captures custom keys with consola. prefix', () => { + sentryReporter.log({ + type: 'info', + message: 'User action', + userId: 123, + sessionId: 'abc-123', + }); - beforeEach(() => { - sentryReporter = createConsolaReporter(); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('User action'); + expect(call.attributes).toMatchObject({ + 'consola.type': 'info', + 'consola.userId': 123, + 'consola.sessionId': 'abc-123', + }); }); - it('should capture error logs', () => { - const logObj = { - type: 'error', - level: 0, - message: 'This is an error', - tag: 'test', - date: new Date('2023-01-01T00:00:00.000Z'), - }; + it('object-first: args=[object] with no message key', () => { + sentryReporter.log({ + type: 'log', + level: 2, + args: [ + { + noMessage: 'obj-no-message', + userId: 123, + action: 'login', + time: '2026-02-24T10:24:04.477Z', + smallObj: { word: 'hi' }, + }, + ], + tag: '', + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe(''); + expect(call.attributes).toMatchObject({ + noMessage: 'obj-no-message', + userId: 123, + action: 'login', + smallObj: { word: 'hi' }, + }); + }); - sentryReporter.log(logObj); + it('object-first: args=[object with message] (e.g. .raw())', () => { + sentryReporter.log({ + type: 'log', + level: 2, + args: [ + { + message: 'raw-obj-message', + userId: 123, + action: 'login', + smallObj: { word: 'hi' }, + }, + ], + tag: '', + }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'error', - message: 'This is an error', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.tag': 'test', - 'consola.type': 'error', - 'consola.level': 0, - }, + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe(''); + expect(call.attributes).toMatchObject({ + message: 'raw-obj-message', + userId: 123, + action: 'login', + smallObj: { word: 'hi' }, }); }); - it('should capture warn logs', () => { - const logObj = { - type: 'warn', - message: 'This is a warning', - }; + it('object-first: args=[object, string message] uses second arg as message', () => { + sentryReporter.log({ + type: 'log', + level: 2, + args: [ + { message: 'obj-message', userId: 123, action: 'login', smallObj: { word: 'hi' } }, + 'additional message obj-message', + ], + tag: '', + }); - sentryReporter.log(logObj); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('additional message obj-message'); + expect(call.attributes).toMatchObject({ + message: 'obj-message', + userId: 123, + action: 'login', + smallObj: { word: 'hi' }, + }); + expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'This is a warning', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'warn', - }, + it('object-first: args=[object, message, ...params] adds params as attributes', () => { + sentryReporter.log({ + type: 'log', + level: 2, + args: [{ message: 'a-message' }, 'additional message a-message', 1234, 'additional-arg'], + tag: '', }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('additional message a-message'); + expect(call.attributes?.message).toBe('a-message'); + expect(call.attributes?.['sentry.message.parameter.0']).toBe(1234); + expect(call.attributes?.['sentry.message.parameter.1']).toBe('additional-arg'); }); - it('should capture info logs', () => { - const logObj = { - type: 'info', - message: 'This is info', - }; + it('fallback: args=[string only] no extra keys', () => { + sentryReporter.log({ type: 'log', args: ['hello'] }); - sentryReporter.log(logObj); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('hello'); + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + }); + }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'This is info', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - }, - }); + describe('level mapping', () => { + it.each([ + ['error', 'error'], + ['warn', 'warn'], + ['info', 'info'], + ['debug', 'debug'], + ['trace', 'trace'], + ['fatal', 'fatal'], + ] as const)('maps type "%s" to Sentry level "%s"', (type, expectedLevel) => { + sentryReporter.log({ type, message: `${type} message` }); + expect(_INTERNAL_captureLog).toHaveBeenCalledWith( + expect.objectContaining({ + level: expectedLevel, + message: `${type} message`, + attributes: expect.objectContaining({ 'consola.type': type }), + }), + ); }); - it('should capture debug logs', () => { - const logObj = { - type: 'debug', - message: 'Debug message', - }; + it.each([ + ['success', 'info'], + ['fail', 'error'], + ['ready', 'info'], + ['start', 'info'], + ['verbose', 'debug'], + ['log', 'info'], + ['silent', 'trace'], + ] as const)('maps consola type "%s" to Sentry level "%s"', (type, expectedLevel) => { + sentryReporter.log({ type, message: `Test ${type}` }); + expect(_INTERNAL_captureLog).toHaveBeenCalledWith( + expect.objectContaining({ + level: expectedLevel, + attributes: expect.objectContaining({ 'consola.type': type }), + }), + ); + }); - sentryReporter.log(logObj); + it('uses level number when type is missing', () => { + sentryReporter.log({ level: 0, message: 'Fatal message' }); + expect(_INTERNAL_captureLog).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'fatal', + message: 'Fatal message', + attributes: expect.objectContaining({ 'consola.level': 0 }), + }), + ); + }); + }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'debug', - message: 'Debug message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'debug', - }, + describe('level filtering', () => { + it('captures only specified levels', () => { + const reporter = createConsolaReporter({ levels: ['error', 'warn'] }); + reporter.log({ type: 'error', message: 'e' }); + reporter.log({ type: 'warn', message: 'w' }); + reporter.log({ type: 'info', message: 'i' }); + expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(2); + }); + + it('captures all default levels when none specified', () => { + ['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(type => { + sentryReporter.log({ type, message: `${type}` }); }); + expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(6); }); + }); + + describe('message and args handling', () => { + it('formats message from args when message not provided (template + params)', () => { + sentryReporter.log({ + type: 'info', + args: ['Hello', 'world', 123, { key: 'value' }], + }); + + expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.level).toBe('info'); + expect(call.message).toContain('Hello'); + expect(call.attributes?.['sentry.message.template']).toBe('Hello {} {} {}'); + expect(call.attributes?.['sentry.message.parameter.0']).toBe('world'); + expect(call.attributes?.['sentry.message.parameter.1']).toBe(123); + expect(call.attributes?.['sentry.message.parameter.2']).toEqual({ key: 'value' }); + }); + + it('handles circular references in args', () => { + const circular: any = {}; + circular.self = circular; + sentryReporter.log({ type: 'info', args: ['Message', circular] }); - it('should capture trace logs', () => { - const logObj = { - type: 'trace', - message: 'Trace message', - }; + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.template']).toBe('Message {}'); + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); + }); - sentryReporter.log(logObj); + it('extracts multiple objects: first as attributes, second as param (object-first)', () => { + sentryReporter.log({ + type: 'info', + message: 'User action', + args: [{ userId: 123 }, { sessionId: 'abc-123' }], + }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'trace', - message: 'Trace message', + level: 'info', + message: 'User action', attributes: { 'sentry.origin': 'auto.log.consola', - 'consola.type': 'trace', + 'consola.type': 'info', + userId: 123, + 'sentry.message.parameter.0': { sessionId: 'abc-123' }, }, }); }); - it('should capture fatal logs', () => { - const logObj = { - type: 'fatal', - message: 'Fatal error', - }; + it('does not override consola.tag or sentry.origin with object properties', () => { + sentryReporter.log({ + type: 'info', + message: 'Test', + tag: 'api', + args: [{ 'sentry.origin': 'no', 'consola.tag': 'no' }, 'Test'], + }); - sentryReporter.log(logObj); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.origin']).toBe('auto.log.consola'); + expect(call.attributes?.['consola.tag']).toBe('api'); + }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Fatal error', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'fatal', - }, + it('respects normalizeDepth in fallback mode', () => { + sentryReporter.log({ + type: 'info', + args: [ + 'Deep', + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + ], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ + level1: { level2: { level3: '[Object]' } }, + simpleKey: 'simple value', }); }); - it('should format message from args when message is not provided', () => { - const logObj = { + it('respects normalizeDepth in object-first mode', () => { + sentryReporter.log({ type: 'info', - args: ['Hello', 'world', 123, { key: 'value' }], - }; + args: [ + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + 'Deep object', + ], + }); - sentryReporter.log(logObj); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('Deep object'); + expect(call.attributes?.level1).toEqual({ level2: { level3: '[Object]' } }); + expect(call.attributes?.simpleKey).toBe('simple value'); + }); - expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Hello world 123 {"key":"value"}', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - }, + it('stores Date and Error in message params (fallback)', () => { + const date = new Date('2023-01-01T00:00:00.000Z'); + const err = new Error('Test error'); + sentryReporter.log({ type: 'info', args: ['Time:', date] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]!.attributes?.['sentry.message.parameter.0']).toBe( + '2023-01-01T00:00:00.000Z', + ); + + vi.clearAllMocks(); + sentryReporter.log({ type: 'error', args: ['Error occurred:', err] }); + const errCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(errCall.attributes?.['sentry.message.parameter.0']).toMatchObject({ + message: 'Test error', + name: 'Error', }); }); - it('should handle args with unparseable objects', () => { - const circular: any = {}; - circular.self = circular; + it('falls back to legacy when first arg is string', () => { + sentryReporter.log({ type: 'info', args: ['Legacy log', { data: 1 }, 123] }); + expect(formatConsoleArgs).toHaveBeenCalledWith(['Legacy log', { data: 1 }, 123], 3, 1000); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.template']).toBe('Legacy log {} {}'); + expect(call.attributes?.data).toBeUndefined(); + }); + + it('falls back to legacy when first arg is array', () => { + sentryReporter.log({ type: 'info', args: [[1, 2, 3], 'Array data'] }); + expect(formatConsoleArgs).toHaveBeenCalled(); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + }); - const logObj = { + it('falls back to legacy when first arg is Error', () => { + sentryReporter.log({ type: 'error', args: [new Error('Test'), 'Error occurred'] }); + expect(formatConsoleArgs).toHaveBeenCalled(); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0].level).toBe('error'); + }); + + it('object-first: empty object as first arg', () => { + sentryReporter.log({ type: 'info', args: [{}, 'Empty object log'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('Empty object log'); + expect(call.attributes?.['sentry.origin']).toBe('auto.log.consola'); + }); + + it('object-first: non-string second arg yields empty message', () => { + sentryReporter.log({ type: 'info', args: [{ userId: 999 }, 123, 'other'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe(''); + expect(call.attributes?.userId).toBe(999); + expect(call.attributes?.['sentry.message.parameter.0']).toBe(123); + expect(call.attributes?.['sentry.message.parameter.1']).toBe('other'); + }); + + it('only extracts attributes from plain objects (not Error)', () => { + sentryReporter.log({ type: 'info', - args: ['Message', circular], - }; + args: ['Mixed:', { userId: 123, name: 'test' }, new Error('test')], + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ userId: 123, name: 'test' }); + expect(call.attributes?.['sentry.message.parameter.1']).toMatchObject({ message: 'test', name: 'Error' }); + expect(call.attributes?.userId).toBeUndefined(); + }); + }); - sentryReporter.log(logObj); + describe('custom extractAttributes', () => { + it('uses custom extraction when provided', () => { + const reporter = createConsolaReporter({ + extractAttributes: args => + args[0] === 'CUSTOM' + ? { attributes: { customExtraction: true }, message: 'Custom message', remainingArgs: [] } + : null, + }); + reporter.log({ type: 'info', args: ['CUSTOM', 'ignored'] }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', - message: 'Message {"self":"[Circular ~]"}', + message: 'Custom message', attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + customExtraction: true, }, }); }); - it('should map consola levels to sentry levels when type is not provided', () => { - const logObj = { - level: 0, // Fatal level - message: 'Fatal message', - }; - - sentryReporter.log(logObj); + it('falls back to default when custom returns null', () => { + const reporter = createConsolaReporter({ extractAttributes: () => null }); + reporter.log({ type: 'info', args: [{ userId: 123 }, 'Fallback to default'] }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Fatal message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.level': 0, - }, + level: 'info', + message: 'Fallback to default', + attributes: expect.objectContaining({ userId: 123 }), }); }); - it('should map various consola types correctly', () => { - const testCases = [ - { type: 'success', expectedLevel: 'info' }, - { type: 'fail', expectedLevel: 'error' }, - { type: 'ready', expectedLevel: 'info' }, - { type: 'start', expectedLevel: 'info' }, - { type: 'verbose', expectedLevel: 'debug' }, - { type: 'log', expectedLevel: 'info' }, - { type: 'silent', expectedLevel: 'trace' }, - ]; - - testCases.forEach(({ type, expectedLevel }) => { - vi.clearAllMocks(); - - sentryReporter.log({ - type, - message: `Test ${type} message`, - }); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: expectedLevel, - message: `Test ${type} message`, - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': type, - }, - }); + it('custom can return only attributes or only message', () => { + const attrOnly = createConsolaReporter({ + extractAttributes: args => ({ attributes: { custom: args[0] } }), }); - }); - }); + attrOnly.log({ type: 'info', args: ['test-value'] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0].attributes?.custom).toBe('test-value'); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0].message).toBe(''); - describe('level filtering', () => { - it('should only capture specified levels', () => { - const filteredReporter = createConsolaReporter({ - levels: ['error', 'warn'], + vi.clearAllMocks(); + const msgOnly = createConsolaReporter({ + extractAttributes: args => ({ message: `Formatted: ${args[0]}` }), }); + msgOnly.log({ type: 'info', args: ['test'] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0].message).toBe('Formatted: test'); + }); - // Should capture error - filteredReporter.log({ - type: 'error', - message: 'Error message', + it('custom remainingArgs=[] omits params', () => { + const reporter = createConsolaReporter({ + extractAttributes: args => ({ + attributes: { allConsumed: true }, + message: String(args[0]), + remainingArgs: [], + }), }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(1); + reporter.log({ type: 'info', args: ['value', 'extra'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('value'); + expect(call.attributes?.allConsumed).toBe(true); + expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + }); - // Should capture warn - filteredReporter.log({ - type: 'warn', - message: 'Warn message', + it('uses consolaMessage when custom does not provide message', () => { + const reporter = createConsolaReporter({ + extractAttributes: () => ({ attributes: { custom: true } }), }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(2); + reporter.log({ type: 'info', message: 'From consola', args: ['ignored'] }); - // Should not capture info - filteredReporter.log({ - type: 'info', - message: 'Info message', - }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(2); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('From consola'); + expect(call.attributes?.custom).toBe(true); }); - it('should use default levels when none specified', () => { - const defaultReporter = createConsolaReporter(); - - // Should capture all default levels - ['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(type => { - defaultReporter.log({ - type, - message: `${type} message`, - }); + it('handles custom extraction with circular refs in remainingArgs', () => { + const circular: any = { self: null }; + circular.self = circular; + const reporter = createConsolaReporter({ + extractAttributes: () => ({ message: 'Test', remainingArgs: [circular] }), }); + reporter.log({ type: 'info', args: ['value'] }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(6); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); }); }); });