From 44ce3dac1a0601eabf6d02e2e63e3648fc354735 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:00:58 +0100 Subject: [PATCH 1/6] feat(consola): Enhance Consola integration to extract objects as searchable attributes --- .../suites/consola/subject-object-context.ts | 38 ++++++ .../suites/consola/test.ts | 111 +++++++++++++++++- packages/core/src/integrations/consola.ts | 51 ++++++-- .../test/lib/integrations/consola.test.ts | 108 ++++++++++++++++- 4 files changed, 293 insertions(+), 15 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/consola/subject-object-context.ts 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..bc51284a05b9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts @@ -0,0 +1,38 @@ +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); + + // Object context extraction - objects should become searchable attributes + consola.info('User logged in', { userId: 123, sessionId: 'abc-123' }); + + // Multiple objects - properties should be merged + consola.warn('Payment processed', { orderId: 456 }, { amount: 99.99, currency: 'USD' }); + + // Mixed primitives and objects + consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true }); + + // Aarrays (should be stored as context attributes) + consola.debug('Processing items', [1, 2, 3, 4, 5]); + + // Nested objects + consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } }); + + 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..69c454949b02 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,112 @@ 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.context.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' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); }); diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 4781b253b161..a2c850cddc97 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -3,6 +3,7 @@ import { getClient } from '../currentScopes'; import { _INTERNAL_captureLog } from '../logs/internal'; import { formatConsoleArgs } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; +import { isPrimitive } from '../utils/is'; /** * Options for the Sentry Consola reporter. @@ -206,17 +207,7 @@ 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 + // Build base attributes first attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { @@ -232,6 +223,44 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } + // Process args: separate primitives for message, extract objects as attributes + let message = consolaMessage || ''; + if (args?.length) { + const primitives: unknown[] = []; + let contextIndex = 0; + + for (const arg of args) { + if (isPrimitive(arg)) { + primitives.push(arg); + } else if (typeof arg === 'object' && arg !== null) { + // Plain objects: extract properties as attributes + if (!Array.isArray(arg)) { + try { + for (const key in arg) { + // Only add if not conflicting with existing or consola-prefixed attributes + if (!(key in attributes) && !(`consola.${key}` in attributes)) { + attributes[key] = (arg as Record)[key]; + } + } + } catch { + // Skip on error + } + } else { + // Arrays: store as context attribute as they don't have meaningful property names, just numeric indices + attributes[`consola.context.${contextIndex++}`] = arg; + } + } else { + primitives.push(arg); + } + } + + if (primitives.length) { + message = message + ? `${message} ${formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth)}` + : formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth); + } + } + _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..576097579a43 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -184,13 +184,14 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); - expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); + expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123], 3, 1000); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', - message: 'Hello world 123 {"key":"value"}', + message: 'Hello world 123', attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + key: 'value', }, }); }); @@ -208,14 +209,115 @@ describe('createConsolaReporter', () => { expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', - message: 'Message {"self":"[Circular ~]"}', + message: 'Message', attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + self: circular, }, }); }); + it('should extract multiple objects as attributes', () => { + const logObj = { + type: 'info', + message: 'User action', + args: [{ userId: 123 }, { sessionId: 'abc-123' }], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'User action', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 123, + sessionId: 'abc-123', + }, + }); + }); + + it('should handle mixed primitives and objects in args', () => { + const logObj = { + type: 'info', + args: ['Processing', { userId: 456 }, 'for', { action: 'login' }], + }; + + sentryReporter.log(logObj); + + expect(formatConsoleArgs).toHaveBeenCalledWith(['Processing', 'for'], 3, 1000); + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Processing for', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 456, + action: 'login', + }, + }); + }); + + it('should handle arrays as context attributes', () => { + const logObj = { + type: 'info', + message: 'Array data', + args: [[1, 2, 3]], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Array data', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + 'consola.context.0': [1, 2, 3], + }, + }); + }); + + it('should not override existing attributes with object properties', () => { + const logObj = { + type: 'info', + message: 'Test', + tag: 'api', + args: [{ tag: 'should-not-override' }], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + 'consola.tag': 'api', + // tag should not be overridden by the object arg + }, + }); + }); + + it('should handle objects with nested properties', () => { + const logObj = { + type: 'info', + args: ['Event', { user: { id: 123, name: 'John' }, timestamp: Date.now() }], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Event'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes.user).toEqual({ id: 123, name: 'John' }); + expect(captureCall.attributes.timestamp).toEqual(expect.any(Number)); + }); + it('should map consola levels to sentry levels when type is not provided', () => { const logObj = { level: 0, // Fatal level From 41bbfb678c9ff7309dcf7174446ffd926b949220 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:48:02 +0100 Subject: [PATCH 2/6] normalize depth --- .../suites/consola/subject-object-context.ts | 14 ++++++++- .../suites/consola/test.ts | 20 +++++++++++++ packages/core/src/integrations/consola.ts | 8 ++++- .../test/lib/integrations/consola.test.ts | 30 ++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) 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 index bc51284a05b9..68bbb86fa6b0 100644 --- a/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts @@ -25,12 +25,24 @@ async function run(): Promise { // Mixed primitives and objects consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true }); - // Aarrays (should be stored as context attributes) + // Arrays (should be stored as context attributes) consola.debug('Processing items', [1, 2, 3, 4, 5]); // Nested objects consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } }); + // Deep nesting to test normalizeDepth (should be normalized at depth 3 - default) + consola.info('Deep object', { + level1: { + level2: { + level3: { + level4: { level5: 'should be normalized' }, + }, + }, + }, + simpleKey: 'simple value', + }); + await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index 69c454949b02..266361d6ce1b 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -593,6 +593,26 @@ describe('consola integration', () => { 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' }, + }, + }, ], }, }) diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index a2c850cddc97..988575ef9882 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -4,6 +4,7 @@ import { _INTERNAL_captureLog } from '../logs/internal'; import { formatConsoleArgs } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; import { isPrimitive } from '../utils/is'; +import { normalize } from '../utils/normalize'; /** * Options for the Sentry Consola reporter. @@ -239,7 +240,12 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con for (const key in arg) { // Only add if not conflicting with existing or consola-prefixed attributes if (!(key in attributes) && !(`consola.${key}` in attributes)) { - attributes[key] = (arg as Record)[key]; + // Normalize the value to respect normalizeDepth + attributes[key] = normalize( + (arg as Record)[key], + normalizeDepth, + normalizeMaxBreadth, + ); } } } catch { diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 576097579a43..70e0c99af9eb 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -213,7 +213,7 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', - self: circular, + self: { self: '[Circular ~]' }, }, }); }); @@ -318,6 +318,34 @@ describe('createConsolaReporter', () => { expect(captureCall.attributes.timestamp).toEqual(expect.any(Number)); }); + it('should respect normalizeDepth when extracting object properties', () => { + const logObj = { + type: 'info', + args: [ + 'Deep object', + { + level1: { + level2: { + level3: { + level4: { level5: 'should be normalized' }, // beause of normalizeDepth=3 + }, + }, + }, + simpleKey: 'simple value', + }, + ], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Deep object'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes.level1).toEqual({ level2: { level3: { level4: '[Object]' } } }); + expect(captureCall.attributes.simpleKey).toBe('simple value'); + }); + it('should map consola levels to sentry levels when type is not provided', () => { const logObj = { level: 0, // Fatal level From 0b71794d789d4126547fed80c2af3f8eda3e1e20 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:26:51 +0100 Subject: [PATCH 3/6] handle Date/Set/Map --- .../suites/consola/subject-special-objects.ts | 39 ++++++ .../suites/consola/test.ts | 113 +++++++++++++++++- packages/core/src/integrations/consola.ts | 18 ++- .../test/lib/integrations/consola.test.ts | 94 ++++++++++++++- 4 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts 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..a39d381b822e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts @@ -0,0 +1,39 @@ +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: plain object + special object + consola.info('Mixed data', { userId: 123 }, new Date('2023-06-15T12:00:00.000Z')); + + 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 266361d6ce1b..37af724baf0b 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -571,7 +571,7 @@ describe('consola integration', () => { 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'debug', type: 'string' }, 'consola.level': { value: 4, type: 'integer' }, - 'consola.context.0': { value: '[1,2,3,4,5]', type: 'string' }, + 'consola.args.0': { value: '[1,2,3,4,5]', type: 'string' }, }, }, { @@ -620,4 +620,115 @@ describe('consola integration', () => { 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', + 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 are extracted + userId: { value: 123, type: 'integer' }, + // Date is preserved in context + 'consola.args.0': { value: '"2023-06-15T12:00:00.000Z"', type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); }); diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 988575ef9882..d6491dd42a09 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -3,7 +3,7 @@ import { getClient } from '../currentScopes'; import { _INTERNAL_captureLog } from '../logs/internal'; import { formatConsoleArgs } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; -import { isPrimitive } from '../utils/is'; +import { isPlainObject, isPrimitive } from '../utils/is'; import { normalize } from '../utils/normalize'; /** @@ -234,26 +234,24 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con if (isPrimitive(arg)) { primitives.push(arg); } else if (typeof arg === 'object' && arg !== null) { - // Plain objects: extract properties as attributes - if (!Array.isArray(arg)) { + // Plain objects: extract properties as individual attributes + if (isPlainObject(arg)) { try { for (const key in arg) { // Only add if not conflicting with existing or consola-prefixed attributes if (!(key in attributes) && !(`consola.${key}` in attributes)) { // Normalize the value to respect normalizeDepth - attributes[key] = normalize( - (arg as Record)[key], - normalizeDepth, - normalizeMaxBreadth, - ); + attributes[key] = normalize(arg[key], normalizeDepth, normalizeMaxBreadth); } } } catch { // Skip on error } } else { - // Arrays: store as context attribute as they don't have meaningful property names, just numeric indices - attributes[`consola.context.${contextIndex++}`] = arg; + // Non-plain objects (Date, Error, Map, Set, etc.) and arrays: Store as args attribute so they get properly serialized + // Special handling for Map and Set to preserve their data + attributes[`consola.args.${contextIndex++}`] = + arg instanceof Map ? Object.fromEntries(arg) : arg instanceof Set ? Array.from(arg) : arg; } } else { primitives.push(arg); diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 70e0c99af9eb..df39d3664462 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -275,7 +275,7 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', - 'consola.context.0': [1, 2, 3], + 'consola.args.0': [1, 2, 3], }, }); }); @@ -346,6 +346,98 @@ describe('createConsolaReporter', () => { expect(captureCall.attributes.simpleKey).toBe('simple value'); }); + it('should store Date objects as context attributes', () => { + const now = new Date('2023-01-01T00:00:00.000Z'); + const logObj = { + type: 'info', + args: ['Current time:', now], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Current time:'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['consola.args.0']).toBe(now); + }); + + it('should store Error objects as context attributes', () => { + const error = new Error('Test error'); + const logObj = { + type: 'error', + args: ['Error occurred:', error], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('error'); + expect(captureCall.message).toBe('Error occurred:'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['consola.args.0']).toBe(error); + }); + + it('should store RegExp objects as context attributes', () => { + const pattern = /test/gi; + const logObj = { + type: 'info', + args: ['Pattern:', pattern], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Pattern:'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['consola.args.0']).toBe(pattern); + }); + + it('should store Map and Set objects as context attributes', () => { + const map = new Map([ + ['key', 'value'], + ['foo', 'bar'], + ]); + const set = new Set([1, 2, 3]); + const logObj = { + type: 'info', + args: ['Collections:', map, set], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Collections:'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + // Map should be converted to plain object + expect(captureCall.attributes['consola.args.0']).toEqual({ key: 'value', foo: 'bar' }); + // Set should be converted to array + expect(captureCall.attributes['consola.args.1']).toEqual([1, 2, 3]); + }); + + it('should only extract properties from plain objects', () => { + const plainObj = { userId: 123, name: 'test' }; + const error = new Error('test'); + const logObj = { + type: 'info', + args: ['Mixed:', plainObj, error], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Mixed:'); + // Plain object properties should be extracted + expect(captureCall.attributes.userId).toBe(123); + expect(captureCall.attributes.name).toBe('test'); + // Error should NOT have properties extracted (stored as context instead) + expect(captureCall.attributes.message).toBeUndefined(); + expect(captureCall.attributes['consola.args.0']).toBe(error); + }); + it('should map consola levels to sentry levels when type is not provided', () => { const logObj = { level: 0, // Fatal level From dfb0a818beade24345037ae5faf3914367e7ad2d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:33:04 +0100 Subject: [PATCH 4/6] add mixed data consola message --- .../suites/consola/subject-special-objects.ts | 11 ++++++++--- .../node-integration-tests/suites/consola/test.ts | 10 +++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) 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 index a39d381b822e..e53006863b16 100644 --- a/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts +++ b/dev-packages/node-integration-tests/suites/consola/subject-special-objects.ts @@ -28,12 +28,17 @@ async function run(): Promise { // Test Map and Set objects are preserved consola.info('Collections:', new Map([['key', 'value']]), new Set([1, 2, 3])); - // Test mixed: plain object + special object - consola.info('Mixed data', { userId: 123 }, new Date('2023-06-15T12:00:00.000Z')); + // 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 37af724baf0b..59031ed74330 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -706,7 +706,7 @@ describe('consola integration', () => { { timestamp: expect.any(Number), level: 'info', - body: 'Mixed data', + body: 'Mixed data a-simple-string', severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { @@ -718,10 +718,14 @@ describe('consola integration', () => { 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, 'consola.level': { value: 3, type: 'integer' }, - // Plain object properties are extracted + // Plain object properties extracted userId: { value: 123, type: 'integer' }, - // Date is preserved in context + // 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' }, }, }, ], From 35c34e1b0d4ef53fb62bb57cb0cbd5f26b5eb5a9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:30:08 +0100 Subject: [PATCH 5/6] generally works --- .../suites/consola/subject-object-context.ts | 29 +- packages/core/src/integrations/consola.ts | 271 ++++++-- .../test/lib/integrations/consola.test.ts | 656 ++++++++++++++++-- 3 files changed, 839 insertions(+), 117 deletions(-) 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 index 68bbb86fa6b0..d0815f5237f3 100644 --- a/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-context.ts @@ -16,22 +16,19 @@ async function run(): Promise { const sentryReporter = Sentry.createConsolaReporter(); consola.addReporter(sentryReporter); - // Object context extraction - objects should become searchable attributes + // --- 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' }); - // Multiple objects - properties should be merged + // Expected: message = formatted string, template + params for each following arg consola.warn('Payment processed', { orderId: 456 }, { amount: 99.99, currency: 'USD' }); - // Mixed primitives and objects consola.error('Error occurred', 'in payment module', { errorCode: 'E001', retryable: true }); - // Arrays (should be stored as context attributes) consola.debug('Processing items', [1, 2, 3, 4, 5]); - // Nested objects consola.info('Complex data', { user: { id: 789, name: 'Jane' }, metadata: { source: 'api' } }); - // Deep nesting to test normalizeDepth (should be normalized at depth 3 - default) consola.info('Deep object', { level1: { level2: { @@ -43,6 +40,26 @@ async function run(): Promise { 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(); } diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index d6491dd42a09..0b2e6e775912 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -1,11 +1,31 @@ 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, isPrimitive } from '../utils/is'; +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. */ @@ -39,6 +59,34 @@ 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 + * } + * }); + * ``` + */ + extractAttributes?: (args: unknown[]) => ExtractAttributesResult | null | undefined; } export interface ConsolaReporter { @@ -64,6 +112,7 @@ export interface ConsolaReporter { export interface ConsolaLogObject { /** * Allows additional custom properties to be set on the log object. + * todo: they are not prefixed? * These properties will be captured as log attributes with a 'consola.' prefix. * * @example @@ -75,6 +124,7 @@ export interface ConsolaLogObject { * userId: 123, * sessionId: 'abc-123' * }); + * todo: NOOO it does not? * // Will create attributes: consola.userId and consola.sessionId * ``` */ @@ -147,12 +197,139 @@ export interface ConsolaLogObject { * * When provided, this is the final formatted message. When not provided, * the message should be constructed from the `args` array. + * + * In reporters, this is probably always undefined: 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 in templateAttrs) { + const value = templateAttrs[key]; + 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 in extractedAttrs) { + // Only add if not conflicting with existing or consola-prefixed attributes + if (!(key in attributes) && !(`consola.${key}` in attributes)) { + attributes[key] = extractedAttrs[key]; + } + } + } + + // 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. * @@ -189,8 +366,13 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con return { 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, date: _date, ...rest } = logObj; + + // Extra keys on logObj (beyond reserved) indicate consola merged a single object, e.g. consola.log({ message: "x", userId: 1 }) + const hasExtraKeys = Object.keys(rest).length > 0; + + // Build attributes: extra keys first, then add reserved base attributes + const attributes: Record = { ...rest }; // Get client - use provided client or current client const client = providedClient || getClient(); @@ -208,7 +390,6 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); - // Build base attributes first attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { @@ -224,47 +405,49 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } - // Process args: separate primitives for message, extract objects as attributes - let message = consolaMessage || ''; - if (args?.length) { - const primitives: unknown[] = []; - let contextIndex = 0; - - for (const arg of args) { - if (isPrimitive(arg)) { - primitives.push(arg); - } else if (typeof arg === 'object' && arg !== null) { - // Plain objects: extract properties as individual attributes - if (isPlainObject(arg)) { - try { - for (const key in arg) { - // Only add if not conflicting with existing or consola-prefixed attributes - if (!(key in attributes) && !(`consola.${key}` in attributes)) { - // Normalize the value to respect normalizeDepth - attributes[key] = normalize(arg[key], normalizeDepth, normalizeMaxBreadth); - } - } - } catch { - // Skip on error - } - } else { - // Non-plain objects (Date, Error, Map, Set, etc.) and arrays: Store as args attribute so they get properly serialized - // Special handling for Map and Set to preserve their data - attributes[`consola.args.${contextIndex++}`] = - arg instanceof Map ? Object.fromEntries(arg) : arg instanceof Set ? Array.from(arg) : arg; - } - } else { - primitives.push(arg); - } - } - - if (primitives.length) { - message = message - ? `${message} ${formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth)}` - : formatConsoleArgs(primitives, normalizeDepth, normalizeMaxBreadth); - } + // Consola-merged: single object was spread by consola (e.g. consola.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 df39d3664462..54c82ccd56c5 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -11,9 +11,13 @@ vi.mock('../../../src/logs/internal', () => ({ _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 => { + const actual = (await importOriginal()) as typeof import('../../../src/logs/utils'); + return { + ...actual, + formatConsoleArgs: vi.fn(actual.formatConsoleArgs), + }; +}); vi.mock('../../../src/currentScopes', () => ({ getClient: vi.fn(), @@ -184,16 +188,19 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); - expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123], 3, 1000); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Hello world 123', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - key: 'value', - }, - }); + // Fallback: message = formatConsoleArgs(all args), template + parameters for args[1:] + expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toContain('Hello'); + expect(captureCall.message).toContain('world'); + expect(captureCall.message).toContain('123'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBe('Hello {} {} {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toBe('world'); + expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); + expect(captureCall.attributes['sentry.message.parameter.2']).toEqual({ key: 'value' }); + expect(captureCall.attributes.key).toBeUndefined(); }); it('should handle args with unparseable objects', () => { @@ -207,15 +214,13 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - self: { self: '[Circular ~]' }, - }, - }); + expect(formatConsoleArgs).toHaveBeenCalledWith(['Message', circular], 3, 1000); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toContain('Message'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBe('Message {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); }); it('should extract multiple objects as attributes', () => { @@ -227,6 +232,7 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Object-first: first arg is object, second is object so message from consolaMessage, remainingArgs = [args[1]] expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', message: 'User action', @@ -234,7 +240,7 @@ describe('createConsolaReporter', () => { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', userId: 123, - sessionId: 'abc-123', + 'sentry.message.parameter.0': { sessionId: 'abc-123' }, }, }); }); @@ -247,17 +253,23 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); - expect(formatConsoleArgs).toHaveBeenCalledWith(['Processing', 'for'], 3, 1000); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Processing for', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - userId: 456, - action: 'login', - }, - }); + // Fallback: first arg is string, message = formatConsoleArgs(all), template + params + expect(formatConsoleArgs).toHaveBeenCalledWith( + ['Processing', { userId: 456 }, 'for', { action: 'login' }], + 3, + 1000, + ); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toContain('Processing'); + expect(captureCall.message).toContain('for'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBe('Processing {} {} {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ userId: 456 }); + expect(captureCall.attributes['sentry.message.parameter.1']).toBe('for'); + expect(captureCall.attributes['sentry.message.parameter.2']).toEqual({ action: 'login' }); + expect(captureCall.attributes.userId).toBeUndefined(); + expect(captureCall.attributes.action).toBeUndefined(); }); it('should handle arrays as context attributes', () => { @@ -269,15 +281,13 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Array data', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - 'consola.args.0': [1, 2, 3], - }, - }); + // Fallback: first arg is array, message = formatConsoleArgs(all), no template (single arg) + expect(formatConsoleArgs).toHaveBeenCalledWith([[1, 2, 3]], 3, 1000); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toMatch(/1.*2.*3/); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['consola.type']).toBe('info'); }); it('should not override existing attributes with object properties', () => { @@ -310,12 +320,17 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: first arg string, message = formatConsoleArgs(all), template + param const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Event'); + expect(captureCall.message).toContain('Event'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes.user).toEqual({ id: 123, name: 'John' }); - expect(captureCall.attributes.timestamp).toEqual(expect.any(Number)); + expect(captureCall.attributes['sentry.message.template']).toBe('Event {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ + user: { id: 123, name: 'John' }, + timestamp: expect.any(Number), + }); + expect(captureCall.attributes.user).toBeUndefined(); }); it('should respect normalizeDepth when extracting object properties', () => { @@ -338,12 +353,16 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: first arg is string, so template + param; param is normalized const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Deep object'); + expect(captureCall.message).toContain('Deep object'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes.level1).toEqual({ level2: { level3: { level4: '[Object]' } } }); - expect(captureCall.attributes.simpleKey).toBe('simple value'); + expect(captureCall.attributes['sentry.message.template']).toBe('Deep object {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ + level1: { level2: { level3: '[Object]' } }, + simpleKey: 'simple value', + }); }); it('should store Date objects as context attributes', () => { @@ -355,11 +374,13 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: template + param (param is normalized, so Date becomes ISO string) const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Current time:'); + expect(captureCall.message).toContain('Current time:'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['consola.args.0']).toBe(now); + expect(captureCall.attributes['sentry.message.template']).toBe('Current time: {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toBe('2023-01-01T00:00:00.000Z'); }); it('should store Error objects as context attributes', () => { @@ -371,11 +392,16 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: template + param (param is normalized, so Error becomes serialized object) const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('error'); - expect(captureCall.message).toBe('Error occurred:'); + expect(captureCall.message).toContain('Error occurred:'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['consola.args.0']).toBe(error); + expect(captureCall.attributes['sentry.message.template']).toBe('Error occurred: {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toMatchObject({ + message: 'Test error', + name: 'Error', + }); }); it('should store RegExp objects as context attributes', () => { @@ -387,11 +413,13 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: template + param (param is normalized, RegExp becomes empty object) const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Pattern:'); + expect(captureCall.message).toContain('Pattern:'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['consola.args.0']).toBe(pattern); + expect(captureCall.attributes['sentry.message.template']).toBe('Pattern: {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({}); }); it('should store Map and Set objects as context attributes', () => { @@ -407,14 +435,17 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: template + params (normalized: Map/Set may become {} and [] depending on normalize) const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Collections:'); + expect(captureCall.message).toContain('Collections:'); expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - // Map should be converted to plain object - expect(captureCall.attributes['consola.args.0']).toEqual({ key: 'value', foo: 'bar' }); - // Set should be converted to array - expect(captureCall.attributes['consola.args.1']).toEqual([1, 2, 3]); + expect(captureCall.attributes['sentry.message.template']).toBe('Collections: {} {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toBeDefined(); + expect(captureCall.attributes['sentry.message.parameter.1']).toBeDefined(); + // normalize() may convert Map to object and Set to array, or both to {} depending on implementation + expect([{ key: 'value', foo: 'bar' }, {}]).toContainEqual(captureCall.attributes['sentry.message.parameter.0']); + expect([[1, 2, 3], [], {}]).toContainEqual(captureCall.attributes['sentry.message.parameter.1']); }); it('should only extract properties from plain objects', () => { @@ -427,15 +458,19 @@ describe('createConsolaReporter', () => { sentryReporter.log(logObj); + // Fallback: first arg string, message = formatConsoleArgs(all), params for plainObj and error const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Mixed:'); - // Plain object properties should be extracted - expect(captureCall.attributes.userId).toBe(123); - expect(captureCall.attributes.name).toBe('test'); - // Error should NOT have properties extracted (stored as context instead) - expect(captureCall.attributes.message).toBeUndefined(); - expect(captureCall.attributes['consola.args.0']).toBe(error); + expect(captureCall.message).toContain('Mixed:'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBe('Mixed: {} {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ userId: 123, name: 'test' }); + expect(captureCall.attributes['sentry.message.parameter.1']).toMatchObject({ + message: 'test', + name: 'Error', + }); + expect(captureCall.attributes.userId).toBeUndefined(); + expect(captureCall.attributes.name).toBeUndefined(); }); it('should map consola levels to sentry levels when type is not provided', () => { @@ -529,4 +564,491 @@ describe('createConsolaReporter', () => { expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(6); }); }); + + describe('structured object-first logging', () => { + let sentryReporter: any; + + beforeEach(() => { + sentryReporter = createConsolaReporter(); + }); + + it('should extract object-first as attributes with string message', () => { + const logObj = { + type: 'info', + args: [{ userId: 123, action: 'login' }, 'User logged in'], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'User logged in', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 123, + action: 'login', + }, + }); + }); + + it('should extract object-first with no message (empty string)', () => { + const logObj = { + type: 'info', + args: [{ userId: 456, status: 'active' }], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: '', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 456, + status: 'active', + }, + }); + }); + + it('should handle object-first with additional parameters', () => { + const requestId = 'req-123'; + const timestamp = 1234567890; + const logObj = { + type: 'info', + args: [{ userId: 789 }, 'User action', requestId, timestamp], + }; + + sentryReporter.log(logObj); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'User action', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 789, + 'sentry.message.parameter.0': requestId, + 'sentry.message.parameter.1': timestamp, + }, + }); + }); + + it('should handle object-first with mixed parameter types', () => { + const logObj = { + type: 'info', + args: [{ event: 'click' }, 'Button clicked', { buttonId: 'submit' }, 123, 'extra'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Button clicked'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes.event).toBe('click'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ buttonId: 'submit' }); + expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); + expect(captureCall.attributes['sentry.message.parameter.2']).toBe('extra'); + }); + + it('should fall back to legacy mode when first arg is not plain object (string)', () => { + const logObj = { + type: 'info', + args: ['Legacy log', { data: 1 }, 123], + }; + + sentryReporter.log(logObj); + + // Fallback: message = formatConsoleArgs(all), template + parameters + expect(formatConsoleArgs).toHaveBeenCalledWith(['Legacy log', { data: 1 }, 123], 3, 1000); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toContain('Legacy log'); + expect(captureCall.message).toContain('123'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBe('Legacy log {} {}'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ data: 1 }); + expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); + expect(captureCall.attributes.data).toBeUndefined(); + }); + + it('should fall back to legacy mode when first arg is array', () => { + const logObj = { + type: 'info', + args: [[1, 2, 3], 'Array data'], + }; + + sentryReporter.log(logObj); + + // Fallback: first arg is array (not string), so no template/params; message = formatConsoleArgs(all) + expect(formatConsoleArgs).toHaveBeenCalledWith([[1, 2, 3], 'Array data'], 3, 1000); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toMatch(/1.*2.*3|Array data/); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['sentry.message.template']).toBeUndefined(); + }); + + it('should fall back to legacy mode when first arg is Error', () => { + const error = new Error('Test error'); + const logObj = { + type: 'error', + args: [error, 'Error occurred'], + }; + + sentryReporter.log(logObj); + + expect(formatConsoleArgs).toHaveBeenCalled(); + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('error'); + }); + + it('should normalize extracted attributes with normalizeDepth', () => { + const logObj = { + type: 'info', + args: [ + { + level1: { + level2: { + level3: { + level4: { level5: 'should be normalized' }, + }, + }, + }, + simpleKey: 'simple value', + }, + 'Deep object', + ], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.level).toBe('info'); + expect(captureCall.message).toBe('Deep object'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + // With normalizeDepth=3, we can see 3 levels deep from the normalized object + expect(captureCall.attributes.level1).toEqual({ level2: { level3: '[Object]' } }); + expect(captureCall.attributes.simpleKey).toBe('simple value'); + }); + + it('should not override sentry.origin and consola.* attributes', () => { + const logObj = { + type: 'info', + tag: 'api', + args: [{ 'sentry.origin': 'should-not-override', 'consola.tag': 'should-not-override' }, 'Test'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + expect(captureCall.attributes['consola.tag']).toBe('api'); + }); + + it('should handle object-first with non-string second argument', () => { + const logObj = { + type: 'info', + args: [{ userId: 999 }, 123, 'other'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.userId).toBe(999); + expect(captureCall.attributes['sentry.message.parameter.0']).toBe(123); + expect(captureCall.attributes['sentry.message.parameter.1']).toBe('other'); + }); + + it('should handle empty object as first arg', () => { + const logObj = { + type: 'info', + args: [{}, 'Empty object log'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('Empty object log'); + expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + }); + + it('should use object-first when first arg is plain object with "message" key', () => { + // consola.log.raw({ message: "hello", userId: 123 }) or similar: first arg is object + const logObj = { + type: 'info', + args: [{ message: 'hello', userId: 123 }], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.message).toBe('hello'); + expect(captureCall.attributes.userId).toBe(123); + }); + + it('should use object-first when first arg is plain object with "args" key', () => { + const logObj = { + type: 'info', + args: [{ args: ['test'], userId: 456 }], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.args).toEqual(['test']); + expect(captureCall.attributes.userId).toBe(456); + }); + + it('should use object-first when object has neither "message" nor "args" keys', () => { + const logObj = { + type: 'info', + args: [{ userId: 789, action: 'click' }, 'Button clicked'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('Button clicked'); + expect(captureCall.attributes.userId).toBe(789); + expect(captureCall.attributes.action).toBe('click'); + }); + + it('should handle .raw() with object containing "message" key (object-first)', () => { + // consola.log.raw({ message: "hello", userId: 456 }): object in args, object-first + const logObj = { + type: 'info', + args: [{ message: 'hello', userId: 456 }], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.message).toBe('hello'); + expect(captureCall.attributes.userId).toBe(456); + }); + + it('should handle .raw() with object without "message"/"args" keys (object-first mode)', () => { + // This simulates consola.log.raw({ userId: 123, action: "click" }) + // When using .raw() with a plain object that doesn't have special keys, + // it should use object-first mode + const logObj = { + type: 'info', + args: [{ userId: 123, action: 'click' }], + // No message property - raw mode + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + // Object-first mode: first object becomes attributes, no message + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.userId).toBe(123); + expect(captureCall.attributes.action).toBe('click'); + }); + + it('should handle .raw() with object and additional string (object-first mode with message)', () => { + // This simulates consola.log.raw({ userId: 999 }, "Custom message") + const logObj = { + type: 'info', + args: [{ userId: 999, status: 'active' }, 'Custom message'], + // No message property - raw mode + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + // Object-first mode: first object becomes attributes, second arg is message + expect(captureCall.message).toBe('Custom message'); + expect(captureCall.attributes.userId).toBe(999); + expect(captureCall.attributes.status).toBe('active'); + }); + + it('should handle consola-merged object (extra keys on logObj, args = single string)', () => { + // consola.log({ message: "inline-message", userId: 123, action: "login" }) → consola merges: + // args: ["inline-message"], and userId, action on logObj + const logObj = { + type: 'log', + level: 2, + args: ['inline-message'], + userId: 123, + action: 'login', + time: '2026-02-24T09:32:12.603Z', + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('inline-message'); + expect(captureCall.attributes.userId).toBe(123); + expect(captureCall.attributes.action).toBe('login'); + expect(captureCall.attributes.time).toBe('2026-02-24T09:32:12.603Z'); + expect(captureCall.attributes['sentry.message.parameter.0']).toBeUndefined(); + }); + + it('should use fallback when args is single string and no extra keys', () => { + // consola.log({ message: "hello" }) → consola gives args: ["hello"], no extra keys + const logObj = { + type: 'log', + args: ['hello'], + }; + + sentryReporter.log(logObj); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('hello'); + expect(captureCall.attributes['sentry.message.template']).toBeUndefined(); + }); + }); + + describe('custom extractAttributes option', () => { + it('should use custom extraction when provided', () => { + const customReporter = createConsolaReporter({ + extractAttributes: args => { + if (args[0] === 'CUSTOM') { + return { + attributes: { customExtraction: true }, + message: 'Custom message', + remainingArgs: [], // All args consumed + }; + } + return null; + }, + }); + + customReporter.log({ + type: 'info', + args: ['CUSTOM', 'ignored'], + }); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Custom message', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + customExtraction: true, + }, + }); + }); + + it('should fall back to default when custom returns null', () => { + const customReporter = createConsolaReporter({ + extractAttributes: () => null, + }); + + customReporter.log({ + type: 'info', + args: [{ userId: 123 }, 'Fallback to default'], + }); + + expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Fallback to default', + attributes: { + 'sentry.origin': 'auto.log.consola', + 'consola.type': 'info', + userId: 123, + }, + }); + }); + + it('should handle custom extraction returning only attributes', () => { + const customReporter = createConsolaReporter({ + extractAttributes: args => ({ + attributes: { custom: args[0] }, + }), + }); + + customReporter.log({ + type: 'info', + args: ['test-value'], + }); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe(''); + expect(captureCall.attributes.custom).toBe('test-value'); + }); + + it('should handle custom extraction returning only message', () => { + const customReporter = createConsolaReporter({ + extractAttributes: args => ({ + message: `Formatted: ${args[0]}`, + }), + }); + + customReporter.log({ + type: 'info', + args: ['test'], + }); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('Formatted: test'); + }); + + it('should handle custom extraction with empty remainingArgs', () => { + const customReporter = createConsolaReporter({ + extractAttributes: args => ({ + attributes: { allConsumed: true }, + message: String(args[0]), + remainingArgs: [], + }), + }); + + customReporter.log({ + type: 'info', + args: ['value', 'extra'], + }); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('value'); + expect(captureCall.attributes.allConsumed).toBe(true); + expect(captureCall.attributes['sentry.message.parameter.0']).toBeUndefined(); + }); + + it('should use consolaMessage if custom extraction does not provide message', () => { + const customReporter = createConsolaReporter({ + extractAttributes: _args => ({ + attributes: { custom: true }, + }), + }); + + customReporter.log({ + type: 'info', + message: 'From consola', + args: ['ignored'], + }); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('From consola'); + expect(captureCall.attributes.custom).toBe(true); + }); + + it('should handle custom extraction with circular references in remainingArgs', () => { + const circular: any = { self: null }; + circular.self = circular; + + const customReporter = createConsolaReporter({ + extractAttributes: _args => ({ + message: 'Test', + remainingArgs: [circular], + }), + }); + + customReporter.log({ + type: 'info', + args: ['value'], + }); + + const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; + expect(captureCall.message).toBe('Test'); + expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); + }); + }); }); From aefd5fe9c25ec24dc82bfdbfa12452f5e5cac3e7 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:09:02 +0100 Subject: [PATCH 6/6] rework consola --- packages/core/src/integrations/consola.ts | 35 +- .../test/lib/integrations/consola.test.ts | 1181 +++++------------ 2 files changed, 340 insertions(+), 876 deletions(-) diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 0b2e6e775912..e81345d479ce 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -86,7 +86,9 @@ interface ConsolaReporterOptions { * }); * ``` */ - extractAttributes?: (args: unknown[]) => ExtractAttributesResult | null | undefined; + // `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 { @@ -111,8 +113,7 @@ export interface ConsolaReporter { */ export interface ConsolaLogObject { /** - * Allows additional custom properties to be set on the log object. - * todo: they are not prefixed? + * 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 @@ -124,7 +125,6 @@ export interface ConsolaLogObject { * userId: 123, * sessionId: 'abc-123' * }); - * todo: NOOO it does not? * // Will create attributes: consola.userId and consola.sessionId * ``` */ @@ -198,7 +198,9 @@ export interface ConsolaLogObject { * When provided, this is the final formatted message. When not provided, * the message should be constructed from the `args` array. * - * In reporters, this is probably always undefined: https://github.com/unjs/consola/issues/406#issuecomment-3684792551 + * 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; } @@ -232,7 +234,7 @@ function extractStructuredAttributes( // Extract attributes from first arg const attributes = normalize(firstArg, normalizeDepth, normalizeMaxBreadth) as Record; - // Determine message (second arg if string, otherwise empty) + // Determine message (second arg if 'string', otherwise empty) const secondArg = args[1]; const message = typeof secondArg === 'string' ? secondArg : ''; @@ -276,8 +278,7 @@ function processArgsFallbackMode( if (followingArgs.length > 0 && typeof firstArg === 'string' && !hasConsoleSubstitutions(firstArg)) { const templateAttrs = createConsoleTemplateAttributes(firstArg, followingArgs); - for (const key in templateAttrs) { - const value = templateAttrs[key]; + for (const [key, value] of Object.entries(templateAttrs)) { messageAttributes[key] = key.startsWith('sentry.message.parameter.') ? normalize(value, normalizeDepth, normalizeMaxBreadth) : value; @@ -312,10 +313,10 @@ function processStructuredMode( // Add extracted attributes, but don't override existing or consola-prefixed attributes if (extractedAttrs) { - for (const key in 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] = extractedAttrs[key]; + attributes[key] = value; } } } @@ -365,14 +366,18 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const providedClient = options.client; return { + // eslint-disable-next-line complexity log(logObj: ConsolaLogObject) { - const { type, level, message: consolaMessage, args, tag, date: _date, ...rest } = logObj; + const { type, level, message: consolaMessage, args, tag, ...rest } = logObj; - // Extra keys on logObj (beyond reserved) indicate consola merged a single object, e.g. consola.log({ message: "x", userId: 1 }) + // Extra keys on logObj (beyond reserved) indicate direct `reporter.log({ type, message, ...rest })` const hasExtraKeys = Object.keys(rest).length > 0; - // Build attributes: extra keys first, then add reserved base attributes - const attributes: Record = { ...rest }; + // 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(); @@ -405,7 +410,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } - // Consola-merged: single object was spread by consola (e.g. consola.log({ message: "inline-message", userId, action })) + // 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({ diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 54c82ccd56c5..3c0fe493239b 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -5,14 +5,14 @@ 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 importOriginal => { - const actual = (await importOriginal()) as typeof import('../../../src/logs/utils'); + // 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), @@ -26,601 +26,275 @@ 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(); - - expect(reporter).toEqual({ - log: expect.any(Function), - }); - }); + it('creates a reporter with a log function', () => { + const reporter = createConsolaReporter(); + expect(reporter).toEqual({ log: expect.any(Function) }); }); - describe('log capturing', () => { - let sentryReporter: any; - - beforeEach(() => { - sentryReporter = createConsolaReporter(); - }); - - 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'), - }; - - sentryReporter.log(logObj); - - 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, - }, - }); - }); - - it('should capture warn logs', () => { - const logObj = { - type: 'warn', - message: 'This is a warning', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'This is a warning', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'warn', - }, - }); - }); - - it('should capture info logs', () => { - const logObj = { - type: 'info', - message: 'This is info', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'This is info', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - }, - }); - }); - - it('should capture debug logs', () => { - const logObj = { - type: 'debug', - message: 'Debug message', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'debug', - message: 'Debug message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'debug', - }, - }); - }); - - it('should capture trace logs', () => { - const logObj = { - type: 'trace', - message: 'Trace message', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'trace', - message: 'Trace message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'trace', - }, + /** + * 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: '', }); - }); - it('should capture fatal logs', () => { - const logObj = { - type: 'fatal', - message: 'Fatal error', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Fatal error', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'fatal', - }, + 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(); }); - it('should format message from args when message is not provided', () => { - const logObj = { - type: 'info', - args: ['Hello', 'world', 123, { key: 'value' }], - }; - - sentryReporter.log(logObj); - - // Fallback: message = formatConsoleArgs(all args), template + parameters for args[1:] - expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Hello'); - expect(captureCall.message).toContain('world'); - expect(captureCall.message).toContain('123'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Hello {} {} {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toBe('world'); - expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); - expect(captureCall.attributes['sentry.message.parameter.2']).toEqual({ key: 'value' }); - expect(captureCall.attributes.key).toBeUndefined(); - }); - - it('should handle args with unparseable objects', () => { - const circular: any = {}; - circular.self = circular; - - const logObj = { - type: 'info', - args: ['Message', circular], - }; - - sentryReporter.log(logObj); - - expect(formatConsoleArgs).toHaveBeenCalledWith(['Message', circular], 3, 1000); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Message'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Message {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); - }); - - it('should extract multiple objects as attributes', () => { - const logObj = { + it('direct reporter.log({ type, message, userId, sessionId }) captures custom keys with consola. prefix', () => { + sentryReporter.log({ type: 'info', message: 'User action', - args: [{ userId: 123 }, { sessionId: 'abc-123' }], - }; - - sentryReporter.log(logObj); - - // Object-first: first arg is object, second is object so message from consolaMessage, remainingArgs = [args[1]] - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'User action', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - userId: 123, - 'sentry.message.parameter.0': { sessionId: 'abc-123' }, - }, + userId: 123, + sessionId: 'abc-123', }); - }); - - it('should handle mixed primitives and objects in args', () => { - const logObj = { - type: 'info', - args: ['Processing', { userId: 456 }, 'for', { action: 'login' }], - }; - - sentryReporter.log(logObj); - - // Fallback: first arg is string, message = formatConsoleArgs(all), template + params - expect(formatConsoleArgs).toHaveBeenCalledWith( - ['Processing', { userId: 456 }, 'for', { action: 'login' }], - 3, - 1000, - ); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Processing'); - expect(captureCall.message).toContain('for'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Processing {} {} {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ userId: 456 }); - expect(captureCall.attributes['sentry.message.parameter.1']).toBe('for'); - expect(captureCall.attributes['sentry.message.parameter.2']).toEqual({ action: 'login' }); - expect(captureCall.attributes.userId).toBeUndefined(); - expect(captureCall.attributes.action).toBeUndefined(); - }); - - it('should handle arrays as context attributes', () => { - const logObj = { - type: 'info', - message: 'Array data', - args: [[1, 2, 3]], - }; - - sentryReporter.log(logObj); - - // Fallback: first arg is array, message = formatConsoleArgs(all), no template (single arg) - expect(formatConsoleArgs).toHaveBeenCalledWith([[1, 2, 3]], 3, 1000); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toMatch(/1.*2.*3/); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['consola.type']).toBe('info'); - }); - - it('should not override existing attributes with object properties', () => { - const logObj = { - type: 'info', - message: 'Test', - tag: 'api', - args: [{ tag: 'should-not-override' }], - }; - - sentryReporter.log(logObj); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - 'consola.tag': 'api', - // tag should not be overridden by the object arg - }, + 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 handle objects with nested properties', () => { - const logObj = { - type: 'info', - args: ['Event', { user: { id: 123, name: 'John' }, timestamp: Date.now() }], - }; - - sentryReporter.log(logObj); + 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: '', + }); - // Fallback: first arg string, message = formatConsoleArgs(all), template + param - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Event'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Event {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ - user: { id: 123, name: 'John' }, - timestamp: expect.any(Number), + 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' }, }); - expect(captureCall.attributes.user).toBeUndefined(); }); - it('should respect normalizeDepth when extracting object properties', () => { - const logObj = { - type: 'info', + it('object-first: args=[object with message] (e.g. .raw())', () => { + sentryReporter.log({ + type: 'log', + level: 2, args: [ - 'Deep object', { - level1: { - level2: { - level3: { - level4: { level5: 'should be normalized' }, // beause of normalizeDepth=3 - }, - }, - }, - simpleKey: 'simple value', + message: 'raw-obj-message', + userId: 123, + action: 'login', + smallObj: { word: 'hi' }, }, ], - }; - - sentryReporter.log(logObj); - - // Fallback: first arg is string, so template + param; param is normalized - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Deep object'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Deep object {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ - level1: { level2: { level3: '[Object]' } }, - simpleKey: 'simple value', + tag: '', }); - }); - - it('should store Date objects as context attributes', () => { - const now = new Date('2023-01-01T00:00:00.000Z'); - const logObj = { - type: 'info', - args: ['Current time:', now], - }; - - sentryReporter.log(logObj); - // Fallback: template + param (param is normalized, so Date becomes ISO string) - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Current time:'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Current time: {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toBe('2023-01-01T00:00:00.000Z'); + 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 store Error objects as context attributes', () => { - const error = new Error('Test error'); - const logObj = { - type: 'error', - args: ['Error occurred:', error], - }; - - sentryReporter.log(logObj); + 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: '', + }); - // Fallback: template + param (param is normalized, so Error becomes serialized object) - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('error'); - expect(captureCall.message).toContain('Error occurred:'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Error occurred: {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toMatchObject({ - message: 'Test error', - name: 'Error', + 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(); }); - it('should store RegExp objects as context attributes', () => { - const pattern = /test/gi; - const logObj = { - type: 'info', - args: ['Pattern:', pattern], - }; - - sentryReporter.log(logObj); + 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: '', + }); - // Fallback: template + param (param is normalized, RegExp becomes empty object) - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Pattern:'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Pattern: {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({}); + 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 store Map and Set objects as context attributes', () => { - const map = new Map([ - ['key', 'value'], - ['foo', 'bar'], - ]); - const set = new Set([1, 2, 3]); - const logObj = { - type: 'info', - args: ['Collections:', map, set], - }; - - sentryReporter.log(logObj); + it('fallback: args=[string only] no extra keys', () => { + sentryReporter.log({ type: 'log', args: ['hello'] }); - // Fallback: template + params (normalized: Map/Set may become {} and [] depending on normalize) - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Collections:'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Collections: {} {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toBeDefined(); - expect(captureCall.attributes['sentry.message.parameter.1']).toBeDefined(); - // normalize() may convert Map to object and Set to array, or both to {} depending on implementation - expect([{ key: 'value', foo: 'bar' }, {}]).toContainEqual(captureCall.attributes['sentry.message.parameter.0']); - expect([[1, 2, 3], [], {}]).toContainEqual(captureCall.attributes['sentry.message.parameter.1']); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('hello'); + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); }); + }); - it('should only extract properties from plain objects', () => { - const plainObj = { userId: 123, name: 'test' }; - const error = new Error('test'); - const logObj = { - type: 'info', - args: ['Mixed:', plainObj, error], - }; - - sentryReporter.log(logObj); - - // Fallback: first arg string, message = formatConsoleArgs(all), params for plainObj and error - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Mixed:'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Mixed: {} {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ userId: 123, name: 'test' }); - expect(captureCall.attributes['sentry.message.parameter.1']).toMatchObject({ - message: 'test', - name: 'Error', - }); - expect(captureCall.attributes.userId).toBeUndefined(); - expect(captureCall.attributes.name).toBeUndefined(); + 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 map consola levels to sentry levels when type is not provided', () => { - const logObj = { - level: 0, // Fatal level - message: 'Fatal message', - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'fatal', - message: 'Fatal message', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.level': 0, - }, - }); + 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 }), + }), + ); }); - 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('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 }), + }), + ); }); }); describe('level filtering', () => { - it('should only capture specified levels', () => { - const filteredReporter = createConsolaReporter({ - levels: ['error', 'warn'], - }); - - // Should capture error - filteredReporter.log({ - type: 'error', - message: 'Error message', - }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(1); - - // Should capture warn - filteredReporter.log({ - type: 'warn', - message: 'Warn message', - }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(2); - - // Should not capture info - filteredReporter.log({ - type: 'info', - message: 'Info message', - }); + 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('should use default levels when none specified', () => { - const defaultReporter = createConsolaReporter(); - - // Should capture all default levels + it('captures all default levels when none specified', () => { ['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(type => { - defaultReporter.log({ - type, - message: `${type} message`, - }); + sentryReporter.log({ type, message: `${type}` }); }); - expect(_INTERNAL_captureLog).toHaveBeenCalledTimes(6); }); }); - describe('structured object-first logging', () => { - let sentryReporter: any; - - beforeEach(() => { - sentryReporter = createConsolaReporter(); - }); - - it('should extract object-first as attributes with string message', () => { - const logObj = { + describe('message and args handling', () => { + it('formats message from args when message not provided (template + params)', () => { + sentryReporter.log({ type: 'info', - args: [{ userId: 123, action: 'login' }, 'User logged in'], - }; - - sentryReporter.log(logObj); - - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'User logged in', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - userId: 123, - action: 'login', - }, + args: ['Hello', 'world', 123, { key: 'value' }], }); - }); - it('should extract object-first with no message (empty string)', () => { - const logObj = { - type: 'info', - args: [{ userId: 456, status: 'active' }], - }; + 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' }); + }); - sentryReporter.log(logObj); + it('handles circular references in args', () => { + const circular: any = {}; + circular.self = circular; + sentryReporter.log({ type: 'info', args: ['Message', circular] }); - expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ - level: 'info', - message: '', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - userId: 456, - status: 'active', - }, - }); + 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 ~]' }); }); - it('should handle object-first with additional parameters', () => { - const requestId = 'req-123'; - const timestamp = 1234567890; - const logObj = { + it('extracts multiple objects: first as attributes, second as param (object-first)', () => { + sentryReporter.log({ type: 'info', - args: [{ userId: 789 }, 'User action', requestId, timestamp], - }; - - sentryReporter.log(logObj); + message: 'User action', + args: [{ userId: 123 }, { sessionId: 'abc-123' }], + }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', @@ -628,304 +302,137 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', - userId: 789, - 'sentry.message.parameter.0': requestId, - 'sentry.message.parameter.1': timestamp, + userId: 123, + 'sentry.message.parameter.0': { sessionId: 'abc-123' }, }, }); }); - it('should handle object-first with mixed parameter types', () => { - const logObj = { + it('does not override consola.tag or sentry.origin with object properties', () => { + sentryReporter.log({ type: 'info', - args: [{ event: 'click' }, 'Button clicked', { buttonId: 'submit' }, 123, 'extra'], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Button clicked'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes.event).toBe('click'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ buttonId: 'submit' }); - expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); - expect(captureCall.attributes['sentry.message.parameter.2']).toBe('extra'); - }); - - it('should fall back to legacy mode when first arg is not plain object (string)', () => { - const logObj = { - type: 'info', - args: ['Legacy log', { data: 1 }, 123], - }; - - sentryReporter.log(logObj); + message: 'Test', + tag: 'api', + args: [{ 'sentry.origin': 'no', 'consola.tag': 'no' }, 'Test'], + }); - // Fallback: message = formatConsoleArgs(all), template + parameters - expect(formatConsoleArgs).toHaveBeenCalledWith(['Legacy log', { data: 1 }, 123], 3, 1000); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toContain('Legacy log'); - expect(captureCall.message).toContain('123'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBe('Legacy log {} {}'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ data: 1 }); - expect(captureCall.attributes['sentry.message.parameter.1']).toBe(123); - expect(captureCall.attributes.data).toBeUndefined(); + 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'); }); - it('should fall back to legacy mode when first arg is array', () => { - const logObj = { + it('respects normalizeDepth in fallback mode', () => { + sentryReporter.log({ type: 'info', - args: [[1, 2, 3], 'Array data'], - }; - - sentryReporter.log(logObj); - - // Fallback: first arg is array (not string), so no template/params; message = formatConsoleArgs(all) - expect(formatConsoleArgs).toHaveBeenCalledWith([[1, 2, 3], 'Array data'], 3, 1000); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toMatch(/1.*2.*3|Array data/); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['sentry.message.template']).toBeUndefined(); - }); - - it('should fall back to legacy mode when first arg is Error', () => { - const error = new Error('Test error'); - const logObj = { - type: 'error', - args: [error, 'Error occurred'], - }; - - sentryReporter.log(logObj); + args: [ + 'Deep', + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + ], + }); - expect(formatConsoleArgs).toHaveBeenCalled(); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('error'); + 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 normalize extracted attributes with normalizeDepth', () => { - const logObj = { + it('respects normalizeDepth in object-first mode', () => { + sentryReporter.log({ type: 'info', args: [ { - level1: { - level2: { - level3: { - level4: { level5: 'should be normalized' }, - }, - }, - }, + level1: { level2: { level3: { level4: 'deep' } } }, simpleKey: 'simple value', }, 'Deep object', ], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.level).toBe('info'); - expect(captureCall.message).toBe('Deep object'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - // With normalizeDepth=3, we can see 3 levels deep from the normalized object - expect(captureCall.attributes.level1).toEqual({ level2: { level3: '[Object]' } }); - expect(captureCall.attributes.simpleKey).toBe('simple value'); - }); - - it('should not override sentry.origin and consola.* attributes', () => { - const logObj = { - type: 'info', - tag: 'api', - args: [{ 'sentry.origin': 'should-not-override', 'consola.tag': 'should-not-override' }, 'Test'], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); - expect(captureCall.attributes['consola.tag']).toBe('api'); - }); - - it('should handle object-first with non-string second argument', () => { - const logObj = { - type: 'info', - args: [{ userId: 999 }, 123, 'other'], - }; - - sentryReporter.log(logObj); + }); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.userId).toBe(999); - expect(captureCall.attributes['sentry.message.parameter.0']).toBe(123); - expect(captureCall.attributes['sentry.message.parameter.1']).toBe('other'); + 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'); }); - it('should handle empty object as first arg', () => { - const logObj = { - type: 'info', - args: [{}, 'Empty object log'], - }; - - sentryReporter.log(logObj); + 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', + ); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('Empty object log'); - expect(captureCall.attributes['sentry.origin']).toBe('auto.log.consola'); + 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 use object-first when first arg is plain object with "message" key', () => { - // consola.log.raw({ message: "hello", userId: 123 }) or similar: first arg is object - const logObj = { - type: 'info', - args: [{ message: 'hello', userId: 123 }], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.message).toBe('hello'); - expect(captureCall.attributes.userId).toBe(123); + 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('should use object-first when first arg is plain object with "args" key', () => { - const logObj = { - type: 'info', - args: [{ args: ['test'], userId: 456 }], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.args).toEqual(['test']); - expect(captureCall.attributes.userId).toBe(456); + 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(); }); - it('should use object-first when object has neither "message" nor "args" keys', () => { - const logObj = { - type: 'info', - args: [{ userId: 789, action: 'click' }, 'Button clicked'], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('Button clicked'); - expect(captureCall.attributes.userId).toBe(789); - expect(captureCall.attributes.action).toBe('click'); + 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('should handle .raw() with object containing "message" key (object-first)', () => { - // consola.log.raw({ message: "hello", userId: 456 }): object in args, object-first - const logObj = { - type: 'info', - args: [{ message: 'hello', userId: 456 }], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.message).toBe('hello'); - expect(captureCall.attributes.userId).toBe(456); + 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('should handle .raw() with object without "message"/"args" keys (object-first mode)', () => { - // This simulates consola.log.raw({ userId: 123, action: "click" }) - // When using .raw() with a plain object that doesn't have special keys, - // it should use object-first mode - const logObj = { - type: 'info', - args: [{ userId: 123, action: 'click' }], - // No message property - raw mode - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - // Object-first mode: first object becomes attributes, no message - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.userId).toBe(123); - expect(captureCall.attributes.action).toBe('click'); + 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('should handle .raw() with object and additional string (object-first mode with message)', () => { - // This simulates consola.log.raw({ userId: 999 }, "Custom message") - const logObj = { + it('only extracts attributes from plain objects (not Error)', () => { + sentryReporter.log({ type: 'info', - args: [{ userId: 999, status: 'active' }, 'Custom message'], - // No message property - raw mode - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - // Object-first mode: first object becomes attributes, second arg is message - expect(captureCall.message).toBe('Custom message'); - expect(captureCall.attributes.userId).toBe(999); - expect(captureCall.attributes.status).toBe('active'); - }); - - it('should handle consola-merged object (extra keys on logObj, args = single string)', () => { - // consola.log({ message: "inline-message", userId: 123, action: "login" }) → consola merges: - // args: ["inline-message"], and userId, action on logObj - const logObj = { - type: 'log', - level: 2, - args: ['inline-message'], - userId: 123, - action: 'login', - time: '2026-02-24T09:32:12.603Z', - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('inline-message'); - expect(captureCall.attributes.userId).toBe(123); - expect(captureCall.attributes.action).toBe('login'); - expect(captureCall.attributes.time).toBe('2026-02-24T09:32:12.603Z'); - expect(captureCall.attributes['sentry.message.parameter.0']).toBeUndefined(); - }); - - it('should use fallback when args is single string and no extra keys', () => { - // consola.log({ message: "hello" }) → consola gives args: ["hello"], no extra keys - const logObj = { - type: 'log', - args: ['hello'], - }; - - sentryReporter.log(logObj); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('hello'); - expect(captureCall.attributes['sentry.message.template']).toBeUndefined(); + 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(); }); }); - describe('custom extractAttributes option', () => { - it('should use custom extraction when provided', () => { - const customReporter = createConsolaReporter({ - extractAttributes: args => { - if (args[0] === 'CUSTOM') { - return { - attributes: { customExtraction: true }, - message: 'Custom message', - remainingArgs: [], // All args consumed - }; - } - return null; - }, - }); - - customReporter.log({ - type: 'info', - args: ['CUSTOM', 'ignored'], + 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', @@ -938,117 +445,69 @@ describe('createConsolaReporter', () => { }); }); - it('should fall back to default when custom returns null', () => { - const customReporter = createConsolaReporter({ - extractAttributes: () => null, - }); - - customReporter.log({ - type: 'info', - args: [{ userId: 123 }, 'Fallback to default'], - }); + 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: 'info', message: 'Fallback to default', - attributes: { - 'sentry.origin': 'auto.log.consola', - 'consola.type': 'info', - userId: 123, - }, - }); - }); - - it('should handle custom extraction returning only attributes', () => { - const customReporter = createConsolaReporter({ - extractAttributes: args => ({ - attributes: { custom: args[0] }, - }), - }); - - customReporter.log({ - type: 'info', - args: ['test-value'], + attributes: expect.objectContaining({ userId: 123 }), }); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe(''); - expect(captureCall.attributes.custom).toBe('test-value'); }); - it('should handle custom extraction returning only message', () => { - const customReporter = createConsolaReporter({ - extractAttributes: args => ({ - message: `Formatted: ${args[0]}`, - }), + 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(''); - customReporter.log({ - type: 'info', - args: ['test'], + vi.clearAllMocks(); + const msgOnly = createConsolaReporter({ + extractAttributes: args => ({ message: `Formatted: ${args[0]}` }), }); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('Formatted: test'); + msgOnly.log({ type: 'info', args: ['test'] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0].message).toBe('Formatted: test'); }); - it('should handle custom extraction with empty remainingArgs', () => { - const customReporter = createConsolaReporter({ + it('custom remainingArgs=[] omits params', () => { + const reporter = createConsolaReporter({ extractAttributes: args => ({ attributes: { allConsumed: true }, message: String(args[0]), remainingArgs: [], }), }); - - customReporter.log({ - type: 'info', - args: ['value', 'extra'], - }); - - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('value'); - expect(captureCall.attributes.allConsumed).toBe(true); - expect(captureCall.attributes['sentry.message.parameter.0']).toBeUndefined(); + 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(); }); - it('should use consolaMessage if custom extraction does not provide message', () => { - const customReporter = createConsolaReporter({ - extractAttributes: _args => ({ - attributes: { custom: true }, - }), - }); - - customReporter.log({ - type: 'info', - message: 'From consola', - args: ['ignored'], + it('uses consolaMessage when custom does not provide message', () => { + const reporter = createConsolaReporter({ + extractAttributes: () => ({ attributes: { custom: true } }), }); + reporter.log({ type: 'info', message: 'From consola', args: ['ignored'] }); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('From consola'); - expect(captureCall.attributes.custom).toBe(true); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('From consola'); + expect(call.attributes?.custom).toBe(true); }); - it('should handle custom extraction with circular references in remainingArgs', () => { + it('handles custom extraction with circular refs in remainingArgs', () => { const circular: any = { self: null }; circular.self = circular; - - const customReporter = createConsolaReporter({ - extractAttributes: _args => ({ - message: 'Test', - remainingArgs: [circular], - }), - }); - - customReporter.log({ - type: 'info', - args: ['value'], + const reporter = createConsolaReporter({ + extractAttributes: () => ({ message: 'Test', remainingArgs: [circular] }), }); + reporter.log({ type: 'info', args: ['value'] }); - const captureCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0][0]; - expect(captureCall.message).toBe('Test'); - expect(captureCall.attributes['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ self: '[Circular ~]' }); }); }); });