diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 9103bbab99b5..e4727848919c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,8 +1,8 @@ export * from './exports'; -import * as logger from './log'; - +import * as logger from './logs/exports'; export { logger }; +export { consoleLoggingIntegration } from './logs/console-integration'; export { reportingObserverIntegration } from './integrations/reportingobserver'; export { httpClientIntegration } from './integrations/httpclient'; diff --git a/packages/browser/src/logs/capture.ts b/packages/browser/src/logs/capture.ts new file mode 100644 index 000000000000..34a41f819b6e --- /dev/null +++ b/packages/browser/src/logs/capture.ts @@ -0,0 +1,73 @@ +import type { Client, Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; +import { getClient, _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '@sentry/core'; +import { WINDOW } from '../helpers'; + +/** + * TODO: Make this configurable + */ +const DEFAULT_FLUSH_INTERVAL = 5000; + +let timeout: ReturnType | undefined; + +/** + * This is a global timeout that is used to flush the logs buffer. + * It is used to ensure that logs are flushed even if the client is not flushed. + */ +function startFlushTimeout(client: Client): void { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(() => { + _INTERNAL_flushLogsBuffer(client); + }, DEFAULT_FLUSH_INTERVAL); +} + +let isClientListenerAdded = false; +/** + * This is a function that is used to add a flush listener to the client. + * It is used to ensure that the logger buffer is flushed when the client is flushed. + */ +function addFlushingListeners(client: Client): void { + if (isClientListenerAdded || !client.getOptions()._experiments?.enableLogs) { + return; + } + + isClientListenerAdded = true; + + if (WINDOW.document) { + WINDOW.document.addEventListener('visibilitychange', () => { + if (WINDOW.document.visibilityState === 'hidden') { + _INTERNAL_flushLogsBuffer(client); + } + }); + } + + client.on('flush', () => { + _INTERNAL_flushLogsBuffer(client); + }); +} + +/** + * Capture a log with the given level. + * + * @param level - The level of the log. + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param severityNumber - The severity number of the log. + */ +export function captureLog( + level: LogSeverityLevel, + message: ParameterizedString, + attributes?: Log['attributes'], + severityNumber?: Log['severityNumber'], +): void { + const client = getClient(); + if (client) { + addFlushingListeners(client); + + startFlushTimeout(client); + } + + _INTERNAL_captureLog({ level, message, attributes, severityNumber }, client, undefined); +} diff --git a/packages/browser/src/logs/console-integration.ts b/packages/browser/src/logs/console-integration.ts new file mode 100644 index 000000000000..2917a8190415 --- /dev/null +++ b/packages/browser/src/logs/console-integration.ts @@ -0,0 +1,69 @@ +import type { ConsoleLevel, IntegrationFn } from '@sentry/core'; +import { + addConsoleInstrumentationHandler, + logger, + CONSOLE_LEVELS, + defineIntegration, + safeJoin, + getClient, +} from '@sentry/core'; +import { captureLog } from './capture'; +import { DEBUG_BUILD } from '../debug-build'; + +interface CaptureConsoleOptions { + levels: ConsoleLevel[]; +} + +const INTEGRATION_NAME = 'ConsoleLogs'; + +const _consoleLoggingIntegration = ((options: Partial = {}) => { + const levels = options.levels || CONSOLE_LEVELS; + + return { + name: INTEGRATION_NAME, + setup(client) { + if (!client.getOptions()._experiments?.enableLogs) { + DEBUG_BUILD && logger.warn('`_experiments.enableLogs` is not enabled, ConsoleLogs integration disabled'); + return; + } + + addConsoleInstrumentationHandler(({ args, level }) => { + if (getClient() !== client || !levels.includes(level)) { + return; + } + + if (level === 'assert') { + if (!args[0]) { + const message = `Assertion failed: ${safeJoin(args.slice(1), ' ') || 'console.assert'}`; + captureLog('error', message); + } + return; + } + + const message = safeJoin(args, ' '); + captureLog(level === 'log' ? 'info' : level, message); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Captures calls to the `console` API as logs in Sentry. Requires `_experiments.enableLogs` to be enabled. + * + * @experimental This feature is experimental and may be changed or removed in future versions. + * + * By default the integration instruments `console.debug`, `console.info`, `console.warn`, `console.error`, + * `console.log`, `console.trace`, and `console.assert`. You can use the `levels` option to customize which + * levels are captured. + * + * @example + * + * ```ts + * import * as Sentry from '@sentry/browser'; + * + * Sentry.init({ + * integrations: [Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] })], + * }); + * ``` + */ +export const consoleLoggingIntegration = defineIntegration(_consoleLoggingIntegration); diff --git a/packages/browser/src/log.ts b/packages/browser/src/logs/exports.ts similarity index 71% rename from packages/browser/src/log.ts rename to packages/browser/src/logs/exports.ts index 23322c168a67..bcc6dda623d6 100644 --- a/packages/browser/src/log.ts +++ b/packages/browser/src/logs/exports.ts @@ -1,77 +1,5 @@ -import type { LogSeverityLevel, Log, Client, ParameterizedString } from '@sentry/core'; -import { getClient, _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '@sentry/core'; - -import { WINDOW } from './helpers'; - -/** - * TODO: Make this configurable - */ -const DEFAULT_FLUSH_INTERVAL = 5000; - -let timeout: ReturnType | undefined; - -/** - * This is a global timeout that is used to flush the logs buffer. - * It is used to ensure that logs are flushed even if the client is not flushed. - */ -function startFlushTimeout(client: Client): void { - if (timeout) { - clearTimeout(timeout); - } - - timeout = setTimeout(() => { - _INTERNAL_flushLogsBuffer(client); - }, DEFAULT_FLUSH_INTERVAL); -} - -let isClientListenerAdded = false; -/** - * This is a function that is used to add a flush listener to the client. - * It is used to ensure that the logger buffer is flushed when the client is flushed. - */ -function addFlushingListeners(client: Client): void { - if (isClientListenerAdded || !client.getOptions()._experiments?.enableLogs) { - return; - } - - isClientListenerAdded = true; - - if (WINDOW.document) { - WINDOW.document.addEventListener('visibilitychange', () => { - if (WINDOW.document.visibilityState === 'hidden') { - _INTERNAL_flushLogsBuffer(client); - } - }); - } - - client.on('flush', () => { - _INTERNAL_flushLogsBuffer(client); - }); -} - -/** - * Capture a log with the given level. - * - * @param level - The level of the log. - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. - * @param severityNumber - The severity number of the log. - */ -function captureLog( - level: LogSeverityLevel, - message: ParameterizedString, - attributes?: Log['attributes'], - severityNumber?: Log['severityNumber'], -): void { - const client = getClient(); - if (client) { - addFlushingListeners(client); - - startFlushTimeout(client); - } - - _INTERNAL_captureLog({ level, message, attributes, severityNumber }, client, undefined); -} +import type { Log, ParameterizedString } from '@sentry/core'; +import { captureLog } from './capture'; /** * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. diff --git a/packages/browser/test/logs/console-integration.test.ts b/packages/browser/test/logs/console-integration.test.ts new file mode 100644 index 000000000000..3660c79db380 --- /dev/null +++ b/packages/browser/test/logs/console-integration.test.ts @@ -0,0 +1,203 @@ +import type { ConsoleLevel } from '@sentry/core'; +import { + GLOBAL_OBJ, + setCurrentClient, + addConsoleInstrumentationHandler, + originalConsoleMethods, + CONSOLE_LEVELS, + resetInstrumentationHandlers, +} from '@sentry/core'; +import * as captureModule from '../../src/logs/capture'; +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { consoleLoggingIntegration } from '../../src/logs/console-integration'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; +import { BrowserClient } from '../../src'; + +const mockConsole: { [key in ConsoleLevel]: Mock } = { + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + assert: vi.fn(), + info: vi.fn(), + trace: vi.fn(), +}; + +describe('CaptureConsole setup', () => { + // Ensure we've initialized the instrumentation so we can get the original one + addConsoleInstrumentationHandler(() => {}); + const _originalConsoleMethods = Object.assign({}, originalConsoleMethods); + + const captureLogSpy = vi.spyOn(captureModule, 'captureLog'); + + let mockClient: BrowserClient; + beforeEach(() => { + CONSOLE_LEVELS.forEach(key => { + originalConsoleMethods[key] = mockConsole[key]; + }); + + mockClient = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + _experiments: { + enableLogs: true, + }, + }); + + setCurrentClient(mockClient); + mockClient.init(); + }); + + afterEach(() => { + vi.clearAllMocks(); + + CONSOLE_LEVELS.forEach(key => { + originalConsoleMethods[key] = _originalConsoleMethods[key]; + }); + + resetInstrumentationHandlers(); + }); + + describe('monkeypatching', () => { + it('should patch user-configured console levels', () => { + const captureConsole = consoleLoggingIntegration({ levels: ['log', 'warn'] }); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.error('msg 1'); + GLOBAL_OBJ.console.log('msg 2'); + GLOBAL_OBJ.console.warn('msg 3'); + + expect(captureLogSpy).toHaveBeenCalledTimes(2); + }); + + it('should fall back to default console levels if none are provided', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + // Assert has a special handling + (['debug', 'info', 'warn', 'error', 'log', 'trace'] as const).forEach(key => { + GLOBAL_OBJ.console[key]('msg'); + }); + + GLOBAL_OBJ.console.assert(false); + + expect(captureLogSpy).toHaveBeenCalledTimes(7); + }); + + it('should not wrap any functions with an empty levels option', () => { + const captureConsole = consoleLoggingIntegration({ levels: [] }); + captureConsole.setup?.(mockClient); + + CONSOLE_LEVELS.forEach(key => { + GLOBAL_OBJ.console[key]('msg'); + }); + + expect(captureLogSpy).toHaveBeenCalledTimes(0); + }); + }); + + it('setup should fail gracefully when console is not available', () => { + const consoleRef = GLOBAL_OBJ.console; + // @ts-expect-error remove console + delete GLOBAL_OBJ.console; + + const captureConsole = consoleLoggingIntegration(); + expect(() => { + captureConsole.setup?.(mockClient); + }).not.toThrow(); + + // reinstate initial console + GLOBAL_OBJ.console = consoleRef; + }); + + describe('experiment flag', () => { + it('should not capture logs when enableLogs is false', () => { + const clientWithoutLogs = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + _experiments: { + enableLogs: false, + }, + }); + + setCurrentClient(clientWithoutLogs); + clientWithoutLogs.init(); + + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(clientWithoutLogs); + + GLOBAL_OBJ.console.log('msg'); + expect(captureLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe('message formatting', () => { + it('should properly format messages with different argument types', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.log('string', 123, { obj: true }, [1, 2, 3]); + expect(captureLogSpy).toHaveBeenCalledWith('info', 'string 123 [object Object] 1,2,3'); + }); + + it('should handle empty arguments', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.log(); + expect(captureLogSpy).toHaveBeenCalledWith('info', ''); + }); + }); + + describe('console.assert', () => { + it('should capture failed assertions as errors', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.assert(false, 'Assertion message'); + expect(captureLogSpy).toHaveBeenCalledWith('error', 'Assertion failed: Assertion message'); + }); + + it('should not capture successful assertions', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.assert(true, 'Assertion message'); + expect(captureLogSpy).not.toHaveBeenCalled(); + }); + + it('should handle assert without message', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + GLOBAL_OBJ.console.assert(false); + expect(captureLogSpy).toHaveBeenCalledWith('error', 'Assertion failed: console.assert'); + }); + }); + + describe('client check', () => { + it('should only capture logs for the current client', () => { + const captureConsole = consoleLoggingIntegration(); + captureConsole.setup?.(mockClient); + + // Create a different client and set it as current + const otherClient = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/456', + _experiments: { + enableLogs: true, + }, + }); + setCurrentClient(otherClient); + otherClient.init(); + + GLOBAL_OBJ.console.log('msg'); + expect(captureLogSpy).not.toHaveBeenCalled(); + + // Set back to original client + setCurrentClient(mockClient); + GLOBAL_OBJ.console.log('msg'); + expect(captureLogSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/browser/test/log.test.ts b/packages/browser/test/logs/exports.test.ts similarity index 98% rename from packages/browser/test/log.test.ts rename to packages/browser/test/logs/exports.test.ts index 9cddc3ecfc71..1067f09b2f75 100644 --- a/packages/browser/test/log.test.ts +++ b/packages/browser/test/logs/exports.test.ts @@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as sentryCore from '@sentry/core'; import { getGlobalScope, getCurrentScope, getIsolationScope } from '@sentry/core'; -import { init, logger } from '../src'; -import { makeSimpleTransport } from './mocks/simpletransport'; +import { init, logger } from '../../src'; +import { makeSimpleTransport } from '../mocks/simpletransport'; const dsn = 'https://53039209a22b4ec1bcc296a3c9fdecd6@sentry.io/4291';