diff --git a/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts b/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts new file mode 100644 index 000000000000..05443b924ab8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts @@ -0,0 +1,28 @@ +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-first: args = [object, string] — first object becomes attributes, second arg is part of formatted message + consola.info({ userId: 100, action: 'login' }, 'User logged in'); + + // Object-first: args = [object] only — object keys become attributes, message is stringified object + consola.info({ event: 'click', count: 2 }); + + 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..5f5028278e47 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -491,4 +491,55 @@ describe('consola integration', () => { await runner.completed(); }); + + test('should capture object-first consola logs (object as first arg)', async () => { + const runner = createRunner(__dirname, 'subject-object-first.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: '{"userId":100,"action":"login"} 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: 100, type: 'integer' }, + action: { value: 'login', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: '{"event":"click","count":2}', + 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' }, + event: { value: 'click', type: 'string' }, + count: { value: 2, type: 'integer' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); }); diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 26ca7b71ab4e..158d2430d4a1 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -1,10 +1,36 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { _INTERNAL_captureLog } from '../logs/internal'; -import { formatConsoleArgs } from '../logs/utils'; +import { createConsoleTemplateAttributes, formatConsoleArgs, hasConsoleSubstitutions } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; +import { isPlainObject } from '../utils/is'; import { normalize } from '../utils/normalize'; +/** + * Result of extracting structured attributes from console arguments. + */ +interface ExtractAttributesResult { + /** + * The log message to use for the log entry, typically constructed from the console arguments. + */ + message?: string; + + /** + * The parameterized template string which is added as `sentry.message.template` attribute if applicable. + */ + messageTemplate?: string; + + /** + * Remaining arguments to process as attributes with keys like `sentry.message.parameter.0`, `sentry.message.parameter.1`, etc. + */ + messageParameters?: unknown[]; + + /** + * Additional attributes to add to the log. + */ + attributes?: Record; +} + /** * Options for the Sentry Consola reporter. */ @@ -125,7 +151,7 @@ export interface ConsolaLogObject { /** * The raw arguments passed to the log method. * - * These args are typically formatted into the final `message`. In Consola reporters, `message` is not provided. + * These args are typically formatted into the final `message`. In Consola reporters, `message` is not provided. See: https://github.com/unjs/consola/issues/406#issuecomment-3684792551 * * @example * ```ts @@ -220,16 +246,6 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); - // Format the log message using the same approach as consola's basic reporter - const messageParts = []; - if (consolaMessage) { - messageParts.push(consolaMessage); - } - if (args && args.length > 0) { - messageParts.push(formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth)); - } - const message = messageParts.join(' '); - const attributes: Record = {}; // Build attributes @@ -252,9 +268,23 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } + const extractionResult = processExtractedAttributes( + defaultExtractAttributes(args, normalizeDepth, normalizeMaxBreadth), + normalizeDepth, + normalizeMaxBreadth, + ); + + if (extractionResult?.attributes) { + Object.assign(attributes, extractionResult.attributes); + } + _INTERNAL_captureLog({ level: logSeverityLevel, - message, + message: + extractionResult?.message || + consolaMessage || + (args && formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth)) || + '', attributes, }); }, @@ -330,3 +360,81 @@ function getLogSeverityLevel(type?: string, level?: number | null): LogSeverityL // Default fallback return 'info'; } + +/** + * Extracts structured attributes from console arguments. If the first argument is a plain object, its properties are extracted as attributes. + */ +function defaultExtractAttributes( + args: unknown[] | undefined, + normalizeDepth: number, + normalizeMaxBreadth: number, +): ExtractAttributesResult { + if (!args?.length) { + return { message: '' }; + } + + // Message looks like how consola logs the message to the console (all args stringified and joined) + const message = formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth); + + const firstArg = args[0]; + + if (isPlainObject(firstArg)) { + // Remaining args start from index 2 i f we used second arg as message, otherwise from index 1 + const remainingArgsStartIndex = typeof args[1] === 'string' ? 2 : 1; + const remainingArgs = args.slice(remainingArgsStartIndex); + + return { + message, + // Object content from first arg is added as attributes + attributes: firstArg, + // Add remaining args as message parameters + messageParameters: remainingArgs, + }; + } else { + const followingArgs = args.slice(1); + + const shouldAddTemplateAttr = + followingArgs.length > 0 && typeof firstArg === 'string' && !hasConsoleSubstitutions(firstArg); + + return { + message, + messageTemplate: shouldAddTemplateAttr ? firstArg : undefined, + messageParameters: shouldAddTemplateAttr ? followingArgs : undefined, + }; + } +} + +/** + * Processes extracted attributes by normalizing them and preparing message parameter attributes if a template is present. + */ +function processExtractedAttributes( + extractionResult: ExtractAttributesResult, + normalizeDepth: number, + normalizeMaxBreadth: number, +): { message: string | undefined; attributes: Record } { + const { message, attributes, messageTemplate, messageParameters } = extractionResult; + + const messageParamAttributes: Record = {}; + + if (messageTemplate && messageParameters) { + const templateAttrs = createConsoleTemplateAttributes(messageTemplate, messageParameters); + + for (const [key, value] of Object.entries(templateAttrs)) { + messageParamAttributes[key] = key.startsWith('sentry.message.parameter.') + ? normalize(value, normalizeDepth, normalizeMaxBreadth) + : value; + } + } else if (messageParameters && messageParameters.length > 0) { + messageParameters.forEach((arg, index) => { + messageParamAttributes[`sentry.message.parameter.${index}`] = normalize(arg, normalizeDepth, normalizeMaxBreadth); + }); + } + + return { + message: message, + attributes: { + ...normalize(attributes, normalizeDepth, normalizeMaxBreadth), + ...messageParamAttributes, + }, + }; +} diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index e1a32b775e54..0ab7a3cc1e98 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -62,13 +62,81 @@ describe('createConsolaReporter', () => { }); describe('message and args handling', () => { + describe('calling consola with object-only', () => { + it('args=[object] with message key uses only message as log message and other keys as attributes', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + // Calling consola with a `message` key like below will format the log object like here in this test + args: ['Calling: consola.log({ message: "", time: new Date(), userId: 123, smallObj: { word: "hi" } })'], + time: '2026-02-24T10:24:04.477Z', + userId: 123, + smallObj: { word: 'hi' }, + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + 'Calling: consola.log({ message: "", time: new Date(), userId: 123, smallObj: { word: "hi" } })', + ); + expect(call.attributes).toMatchObject({ + time: '2026-02-24T10:24:04.477Z', + userId: 123, + smallObj: { word: 'hi' }, + }); + }); + + it('args=[object] with no message key uses empty message and object as attributes', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + args: [ + { + noMessage: 'Calling: consola.log({ noMessage: "", time: new Date() })', + time: '2026-02-24T10:24:04.477Z', + }, + ], + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"noMessage":"Calling: consola.log({ noMessage: \\"\\", time: new Date() })","time":"2026-02-24T10:24:04.477Z"}', + ); + expect(call.attributes).toMatchObject({ + noMessage: 'Calling: consola.log({ noMessage: "", time: new Date() })', + time: '2026-02-24T10:24:04.477Z', + }); + }); + + it('args=[object with message] keeps message in attributes only (e.g. .raw())', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + args: [ + { + message: 'Calling: consola.raw({ message: "", userId: 123, smallObj: { word: "hi" } })', + userId: 123, + smallObj: { word: 'hi' }, + }, + ], + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"message":"Calling: consola.raw({ message: \\"\\", userId: 123, smallObj: { word: \\"hi\\" } })","userId":123,"smallObj":{"word":"hi"}}', + ); + expect(call.attributes).toMatchObject({ + message: 'Calling: consola.raw({ message: "", userId: 123, smallObj: { word: "hi" } })', + userId: 123, + smallObj: { word: 'hi' }, + }); + }); + }); + it('should format message from args', () => { - const logObj = { + sentryReporter.log({ type: 'info', args: ['Hello', 'world', 123, { key: 'value' }], - }; - - sentryReporter.log(logObj); + }); expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ @@ -77,20 +145,154 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + 'sentry.message.parameter.0': 'world', + 'sentry.message.parameter.1': 123, + 'sentry.message.parameter.2': { key: 'value' }, + 'sentry.message.template': 'Hello {} {} {}', }, }); }); + it('uses consolaMessage when result.message is empty (e.g. args is [])', () => { + sentryReporter.log({ + type: 'info', + message: 'From consola message key', + args: [], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('From consola message key'); + }); + + it('uses formatConsoleArgs when result.message and consolaMessage are falsy but args is truthy', () => { + sentryReporter.log({ + type: 'info', + args: [], + }); + + expect(formatConsoleArgs).toHaveBeenCalledWith([], 3, 1000); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe(''); + }); + + it('overrides consola.tag or sentry.origin with object properties', () => { + sentryReporter.log({ + type: 'info', + message: 'Test', + tag: 'api', + args: [{ 'sentry.origin': 'object-args', 'consola.tag': 'object-args-tag' }, 'Test'], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.origin']).toBe('object-args'); + expect(call.attributes?.['consola.tag']).toBe('object-args-tag'); + }); + + it('respects normalizeDepth in fallback mode', () => { + sentryReporter.log({ + type: 'info', + args: [ + 'Deep', + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + ], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ + level1: { level2: { level3: '[Object]' } }, + simpleKey: 'simple value', + }); + }); + + it('adds additional params in object-first mode', () => { + sentryReporter.log({ + type: 'info', + args: [ + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + 'Deep object', + 12345, + { another: 'object', level1: { level2: { level3: { level4: 'deep' } } } }, + ], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"level1":{"level2":{"level3":"[Object]"}},"simpleKey":"simple value"} Deep object 12345 {"another":"object","level1":{"level2":{"level3":"[Object]"}}}', + ); + expect(call.attributes?.level1).toEqual({ level2: { level3: '[Object]' } }); + expect(call.attributes?.simpleKey).toBe('simple value'); + + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + expect(call.attributes?.['sentry.message.parameter.0']).toBe(12345); + expect(call.attributes?.['sentry.message.parameter.1']).toStrictEqual({ + another: 'object', + level1: { level2: { level3: '[Object]' } }, + }); + }); + + it('stores Date and Error in message params (fallback)', () => { + const date = new Date('2023-01-01T00:00:00.000Z'); + sentryReporter.log({ type: 'info', args: ['Time:', date] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]!.attributes?.['sentry.message.parameter.0']).toBe( + '2023-01-01T00:00:00.000Z', + ); + + vi.clearAllMocks(); + const err = new Error('Test error'); + 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('handles console substitution patterns in first arg', () => { + sentryReporter.log({ type: 'info', args: ['Value: %d, another: %s', 42, 'hello'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + + // We don't substitute as it gets too complicated on the client-side: https://github.com/getsentry/sentry-javascript/pull/17703 + expect(call.message).toBe('Value: %d, another: %s 42 hello'); + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + }); + + it.each([ + ['string', ['Normal log', { data: 1 }, 123], 'Normal log {} {}', undefined], + ['array', [[1, 2, 3], 'Array data'], undefined, undefined], + ['Error', [new Error('Test'), 'Error occurred'], undefined, 'error'], + ] as const)('falls back to non-object extracting when first arg is %s', (_, args, template, level) => { + vi.clearAllMocks(); + // @ts-expect-error Testing legacy fallback + sentryReporter.log({ type: level ?? 'info', args }); + expect(formatConsoleArgs).toHaveBeenCalled(); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + if (template !== undefined) expect(call.attributes?.['sentry.message.template']).toBe(template); + if (template === 'Normal log {} {}') expect(call.attributes?.data).toBeUndefined(); + if (level) expect(call.level).toBe(level); + }); + + 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 args with unparseable objects', () => { const circular: any = {}; circular.self = circular; - const logObj = { + sentryReporter.log({ type: 'info', args: ['Message', circular], - }; - - sentryReporter.log(logObj); + }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', @@ -98,39 +300,29 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + 'sentry.message.template': 'Message {}', + 'sentry.message.parameter.0': { self: '[Circular ~]' }, }, }); }); - it('consola-merged: args=[message] with extra keys on log object', () => { + it('formats message from args when message not provided (template + params)', () => { sentryReporter.log({ - type: 'log', - level: 2, - args: ['Hello', 'world', { some: 'obj' }], - userId: 123, - action: 'login', - time: '2026-02-24T10:24:04.477Z', - smallObj: { firstLevel: { secondLevel: { thirdLevel: { fourthLevel: 'deep' } } } }, - tag: '', + type: 'info', + args: ['Hello', 'world', 123, { key: 'value' }], }); + expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; - - // Message from args - expect(call.message).toBe('Hello world {"some":"obj"}'); - expect(call.attributes).toMatchObject({ - 'consola.type': 'log', - 'consola.level': 2, - userId: 123, - smallObj: { firstLevel: { secondLevel: { thirdLevel: '[Object]' } } }, // Object is normalized - action: 'login', - time: '2026-02-24T10:24:04.477Z', - 'sentry.origin': 'auto.log.consola', - }); - expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + expect(call.level).toBe('info'); + expect(call.message).toContain('Hello'); + expect(call.attributes?.['sentry.message.template']).toBe('Hello {} {} {}'); + expect(call.attributes?.['sentry.message.parameter.0']).toBe('world'); + expect(call.attributes?.['sentry.message.parameter.1']).toBe(123); + expect(call.attributes?.['sentry.message.parameter.2']).toEqual({ key: 'value' }); }); - it('capturing custom keys mimicking direct reporter.log({ type, message, userId, sessionId })', () => { + it('Uses "message" key as fallback message, when no args are available', () => { sentryReporter.log({ type: 'info', message: 'User action', diff --git a/yarn.lock b/yarn.lock index 8ac228517506..c4b44e29e3fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26377,16 +26377,16 @@ rollup-pluginutils@^2.8.2: estree-walker "^0.6.1" rollup@^2.70.0: - version "2.79.2" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090" - integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ== + version "2.80.0" + resolved "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz" + integrity sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ== optionalDependencies: fsevents "~2.3.2" rollup@^3.27.1, rollup@^3.28.1: - version "3.29.5" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" - integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== + version "3.30.0" + resolved "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz" + integrity sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA== optionalDependencies: fsevents "~2.3.2" @@ -28096,7 +28096,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"