diff --git a/packages/browser/src/log.ts b/packages/browser/src/log.ts index 4ae97fd27a94..69f8313a3378 100644 --- a/packages/browser/src/log.ts +++ b/packages/browser/src/log.ts @@ -1,4 +1,4 @@ -import type { LogSeverityLevel, Log, Client } from '@sentry/core'; +import type { LogSeverityLevel, Log, Client, ParameterizedString } from '@sentry/core'; import { getClient, _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '@sentry/core'; import { WINDOW } from './helpers'; @@ -59,7 +59,7 @@ function addFlushingListeners(client: Client): void { */ function captureLog( level: LogSeverityLevel, - message: string, + message: ParameterizedString, attributes?: Log['attributes'], severityNumber?: Log['severityNumber'], ): void { @@ -77,15 +77,28 @@ function captureLog( * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. * * @example * * ``` - * Sentry.logger.trace('Hello world', { userId: 100 }); + * Sentry.logger.trace('User clicked submit button', { + * buttonId: 'submit-form', + * formId: 'user-profile', + * timestamp: Date.now() + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.trace(Sentry.logger.fmt`User ${user} navigated to ${page}`, { + * userId: '123', + * sessionId: 'abc-xyz' + * }); * ``` */ -export function trace(message: string, attributes?: Log['attributes']): void { +export function trace(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('trace', message, attributes); } @@ -93,15 +106,29 @@ export function trace(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. * * @example * * ``` - * Sentry.logger.debug('Hello world', { userId: 100 }); + * Sentry.logger.debug('Component mounted', { + * component: 'UserProfile', + * props: { userId: 123 }, + * renderTime: 150 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.debug(Sentry.logger.fmt`API request to ${endpoint} failed`, { + * statusCode: 404, + * requestId: 'req-123', + * duration: 250 + * }); * ``` */ -export function debug(message: string, attributes?: Log['attributes']): void { +export function debug(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('debug', message, attributes); } @@ -109,15 +136,29 @@ export function debug(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. * * @example * * ``` - * Sentry.logger.info('Hello world', { userId: 100 }); + * Sentry.logger.info('User completed checkout', { + * orderId: 'order-123', + * amount: 99.99, + * paymentMethod: 'credit_card' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.info(Sentry.logger.fmt`User ${user} updated profile picture`, { + * userId: 'user-123', + * imageSize: '2.5MB', + * timestamp: Date.now() + * }); * ``` */ -export function info(message: string, attributes?: Log['attributes']): void { +export function info(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('info', message, attributes); } @@ -125,15 +166,30 @@ export function info(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. * * @example * * ``` - * Sentry.logger.warn('Hello world', { userId: 100 }); + * Sentry.logger.warn('Browser compatibility issue detected', { + * browser: 'Safari', + * version: '14.0', + * feature: 'WebRTC', + * fallback: 'enabled' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.warn(Sentry.logger.fmt`API endpoint ${endpoint} is deprecated`, { + * recommendedEndpoint: '/api/v2/users', + * sunsetDate: '2024-12-31', + * clientVersion: '1.2.3' + * }); * ``` */ -export function warn(message: string, attributes?: Log['attributes']): void { +export function warn(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('warn', message, attributes); } @@ -141,15 +197,31 @@ export function warn(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. * * @example * * ``` - * Sentry.logger.error('Hello world', { userId: 100 }); + * Sentry.logger.error('Failed to load user data', { + * error: 'NetworkError', + * url: '/api/users/123', + * statusCode: 500, + * retryCount: 3 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.error(Sentry.logger.fmt`Payment processing failed for order ${orderId}`, { + * error: 'InsufficientFunds', + * amount: 100.00, + * currency: 'USD', + * userId: 'user-456' + * }); * ``` */ -export function error(message: string, attributes?: Log['attributes']): void { +export function error(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('error', message, attributes); } @@ -157,15 +229,31 @@ export function error(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. * * @example * * ``` - * Sentry.logger.fatal('Hello world', { userId: 100 }); + * Sentry.logger.fatal('Application state corrupted', { + * lastKnownState: 'authenticated', + * sessionId: 'session-123', + * timestamp: Date.now(), + * recoveryAttempted: true + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.fatal(Sentry.logger.fmt`Critical system failure in ${service}`, { + * service: 'payment-processor', + * errorCode: 'CRITICAL_FAILURE', + * affectedUsers: 150, + * timestamp: Date.now() + * }); * ``` */ -export function fatal(message: string, attributes?: Log['attributes']): void { +export function fatal(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('fatal', message, attributes); } @@ -173,14 +261,32 @@ export function fatal(message: string, attributes?: Log['attributes']): void { * @summary Capture a log with the `critical` level. Requires `_experiments.enableLogs` to be enabled. * * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { security: 'breach', severity: 'high' }. * * @example * * ``` - * Sentry.logger.critical('Hello world', { userId: 100 }); + * Sentry.logger.critical('Security breach detected', { + * type: 'unauthorized_access', + * user: '132123', + * endpoint: '/api/admin', + * timestamp: Date.now() + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.critical(Sentry.logger.fmt`Multiple failed login attempts from user ${user}`, { + * attempts: 10, + * timeWindow: '5m', + * blocked: true, + * timestamp: Date.now() + * }); * ``` */ -export function critical(message: string, attributes?: Log['attributes']): void { +export function critical(message: ParameterizedString, attributes?: Log['attributes']): void { captureLog('critical', message, attributes); } + +export { fmt } from '@sentry/core'; diff --git a/packages/browser/test/log.test.ts b/packages/browser/test/log.test.ts index 582cc3b45d20..9dec17fe01e0 100644 --- a/packages/browser/test/log.test.ts +++ b/packages/browser/test/log.test.ts @@ -196,5 +196,38 @@ describe('Logger', () => { vi.advanceTimersByTime(2000); expect(mockFlushLogsBuffer).toHaveBeenCalledTimes(1); }); + + it('should handle parameterized strings with parameters', () => { + logger.info(logger.fmt`Hello ${'John'}, your balance is ${100}`, { userId: 123 }); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'info', + message: expect.objectContaining({ + __sentry_template_string__: 'Hello %s, your balance is %s', + __sentry_template_values__: ['John', 100], + }), + attributes: { + userId: 123, + }, + }, + expect.any(Object), + undefined, + ); + }); + + it('should handle parameterized strings without additional attributes', () => { + logger.debug(logger.fmt`User ${'Alice'} logged in from ${'mobile'}`); + expect(mockCaptureLog).toHaveBeenCalledWith( + { + level: 'debug', + message: expect.objectContaining({ + __sentry_template_string__: 'User %s logged in from %s', + __sentry_template_values__: ['Alice', 'mobile'], + }), + }, + expect.any(Object), + undefined, + ); + }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 292a9d5d9f6d..800df99e1c0e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,7 +66,7 @@ export { hasTracingEnabled } from './utils/hasSpansEnabled'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; -export { parameterize } from './utils/parameterize'; +export { parameterize, fmt } from './utils/parameterize'; export { addAutoIpAddressToSession, addAutoIpAddressToUser } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, diff --git a/packages/core/src/logs/index.ts b/packages/core/src/logs/index.ts index 20b654e39532..fbe1b40493c3 100644 --- a/packages/core/src/logs/index.ts +++ b/packages/core/src/logs/index.ts @@ -5,7 +5,7 @@ import { DEBUG_BUILD } from '../debug-build'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist'; import type { Log } from '../types-hoist/log'; -import { logger } from '../utils-hoist'; +import { isParameterizedString, logger } from '../utils-hoist'; import { _getSpanForScope } from '../utils/spanOnScope'; import { createOtelLogEnvelope } from './envelope'; @@ -100,6 +100,14 @@ export function _INTERNAL_captureLog(beforeLog: Log, client = getClient(), scope logAttributes.environment = environment; } + if (isParameterizedString(message)) { + const { __sentry_template_string__, __sentry_template_values__ = [] } = message; + logAttributes['sentry.message.template'] = __sentry_template_string__; + __sentry_template_values__.forEach((param, index) => { + logAttributes[`sentry.message.param.${index}`] = param; + }); + } + const span = _getSpanForScope(scope); if (span) { // Add the parent span ID to the log attributes for trace context diff --git a/packages/core/src/types-hoist/event.ts b/packages/core/src/types-hoist/event.ts index 5b4d87337236..a042edad2cda 100644 --- a/packages/core/src/types-hoist/event.ts +++ b/packages/core/src/types-hoist/event.ts @@ -22,7 +22,7 @@ export interface Event { message?: string; logentry?: { message?: string; - params?: string[]; + params?: unknown[]; }; timestamp?: number; start_timestamp?: number; diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 45172c44adc0..844c9223dbfd 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,3 +1,5 @@ +import type { ParameterizedString } from './parameterize'; + export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'critical'; export type SerializedLogAttributeValueType = @@ -36,7 +38,7 @@ export interface Log { /** * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' */ - message: string; + message: ParameterizedString; /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. diff --git a/packages/core/src/types-hoist/parameterize.ts b/packages/core/src/types-hoist/parameterize.ts index a94daa3684db..8b62bfd483e9 100644 --- a/packages/core/src/types-hoist/parameterize.ts +++ b/packages/core/src/types-hoist/parameterize.ts @@ -1,4 +1,4 @@ export type ParameterizedString = string & { __sentry_template_string__?: string; - __sentry_template_values__?: string[]; + __sentry_template_values__?: unknown[]; }; diff --git a/packages/core/src/utils/parameterize.ts b/packages/core/src/utils/parameterize.ts index 392f4b70b444..2ada1f4ec2d9 100644 --- a/packages/core/src/utils/parameterize.ts +++ b/packages/core/src/utils/parameterize.ts @@ -5,13 +5,24 @@ import type { ParameterizedString } from '../types-hoist'; * For example: parameterize`This is a log statement with ${x} and ${y} params`, would return: * "__sentry_template_string__": 'This is a log statement with %s and %s params', * "__sentry_template_values__": ['first', 'second'] + * * @param strings An array of string values splitted between expressions * @param values Expressions extracted from template string - * @returns String with template information in __sentry_template_string__ and __sentry_template_values__ properties + * + * @returns A `ParameterizedString` object that can be passed into `captureMessage` or Sentry.logger.X methods. */ -export function parameterize(strings: TemplateStringsArray, ...values: string[]): ParameterizedString { +export function parameterize(strings: TemplateStringsArray, ...values: unknown[]): ParameterizedString { const formatted = new String(String.raw(strings, ...values)) as ParameterizedString; formatted.__sentry_template_string__ = strings.join('\x00').replace(/%/g, '%%').replace(/\0/g, '%s'); formatted.__sentry_template_values__ = values; return formatted; } + +/** + * Tagged template function which returns parameterized representation of the message. + * + * @param strings An array of string values splitted between expressions + * @param values Expressions extracted from template string + * @returns A `ParameterizedString` object that can be passed into `captureMessage` or Sentry.logger.X methods. + */ +export const fmt = parameterize; diff --git a/packages/core/test/lib/log/envelope.test.ts b/packages/core/test/lib/logs/envelope.test.ts similarity index 100% rename from packages/core/test/lib/log/envelope.test.ts rename to packages/core/test/lib/logs/envelope.test.ts diff --git a/packages/core/test/lib/log/index.test.ts b/packages/core/test/lib/logs/index.test.ts similarity index 90% rename from packages/core/test/lib/log/index.test.ts rename to packages/core/test/lib/logs/index.test.ts index 22e76a99e528..e2b7eba781d2 100644 --- a/packages/core/test/lib/log/index.test.ts +++ b/packages/core/test/lib/logs/index.test.ts @@ -7,7 +7,7 @@ import { } from '../../../src/logs'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; import * as loggerModule from '../../../src/utils-hoist/logger'; -import { Scope } from '../../../src'; +import { Scope, fmt } from '../../../src'; import type { Log } from '../../../src/types-hoist/log'; const PUBLIC_DSN = 'https://username@domain/123'; @@ -117,6 +117,7 @@ describe('_INTERNAL_captureLog', () => { expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( expect.objectContaining({ traceId: '3d9355f71e9c444b81161599adac6e29', + severityNumber: 17, // error level maps to 17 }), ); }); @@ -189,6 +190,33 @@ describe('_INTERNAL_captureLog', () => { expect(mockSendEnvelope).not.toHaveBeenCalled(); }); + it('handles parameterized strings correctly', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + const parameterizedMessage = fmt`Hello ${'John'}, welcome to ${'Sentry'}`; + + _INTERNAL_captureLog({ level: 'info', message: parameterizedMessage }, client, undefined); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'sentry.message.template', + value: { stringValue: 'Hello %s, welcome to %s' }, + }), + expect.objectContaining({ + key: 'sentry.message.param.0', + value: { stringValue: 'John' }, + }), + expect.objectContaining({ + key: 'sentry.message.param.1', + value: { stringValue: 'Sentry' }, + }), + ]), + ); + }); + it('processes logs through beforeSendLog when provided', () => { const beforeSendLog = vi.fn().mockImplementation(log => ({ ...log, diff --git a/packages/node/src/log.ts b/packages/node/src/log.ts index 9bad4895ceb6..be5fc934351c 100644 --- a/packages/node/src/log.ts +++ b/packages/node/src/log.ts @@ -1,10 +1,10 @@ import { format } from 'node:util'; -import type { LogSeverityLevel, Log } from '@sentry/core'; +import type { LogSeverityLevel, Log, ParameterizedString } from '@sentry/core'; import { _INTERNAL_captureLog } from '@sentry/core'; type CaptureLogArgs = - | [message: string, attributes?: Log['attributes']] + | [message: ParameterizedString, attributes?: Log['attributes']] | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; /** @@ -221,3 +221,5 @@ export function fatal(...args: CaptureLogArgs): void { export function critical(...args: CaptureLogArgs): void { captureLog('critical', ...args); } + +export { fmt } from '@sentry/core'; diff --git a/packages/node/test/log.test.ts b/packages/node/test/log.test.ts index 4064d7c1f3f1..150e040f5ec5 100644 --- a/packages/node/test/log.test.ts +++ b/packages/node/test/log.test.ts @@ -126,5 +126,30 @@ describe('Node Logger', () => { }, }); }); + + it('should handle parameterized strings with parameters', () => { + nodeLogger.info(nodeLogger.fmt`Hello ${'John'}, your balance is ${100}`, { userId: 123 }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: expect.objectContaining({ + __sentry_template_string__: 'Hello %s, your balance is %s', + __sentry_template_values__: ['John', 100], + }), + attributes: { + userId: 123, + }, + }); + }); + + it('should handle parameterized strings without additional attributes', () => { + nodeLogger.debug(nodeLogger.fmt`User ${'Alice'} logged in from ${'mobile'}`); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: expect.objectContaining({ + __sentry_template_string__: 'User %s logged in from %s', + __sentry_template_values__: ['Alice', 'mobile'], + }), + }); + }); }); });