Skip to content

feat(core): Add support for parameterizing logs #15812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 129 additions & 23 deletions packages/browser/src/log.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -59,7 +59,7 @@ function addFlushingListeners(client: Client): void {
*/
function captureLog(
level: LogSeverityLevel,
message: string,
message: ParameterizedString,
attributes?: Log['attributes'],
severityNumber?: Log['severityNumber'],
): void {
Expand All @@ -77,110 +77,216 @@ 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);
}

/**
* @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);
}

/**
* @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);
}

/**
* @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);
}

/**
* @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);
}

/**
* @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);
}

/**
* @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';
33 changes: 33 additions & 0 deletions packages/browser/test/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types-hoist/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Event {
message?: string;
logentry?: {
message?: string;
params?: string[];
params?: unknown[];
};
timestamp?: number;
start_timestamp?: number;
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/types-hoist/log.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ParameterizedString } from './parameterize';

export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'critical';

export type SerializedLogAttributeValueType =
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types-hoist/parameterize.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ParameterizedString = string & {
__sentry_template_string__?: string;
__sentry_template_values__?: string[];
__sentry_template_values__?: unknown[];
};
15 changes: 13 additions & 2 deletions packages/core/src/utils/parameterize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we deprecate this then or do we just keep both?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna actually remove the fmt alias globally in favour of just leaving it in the logger namespace. We can revaluate this later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually nvm I thought about it again, and let's keep it because it makes the re-export super easy.

We can keep both, and evaluate deprecating parameterize at a later time.

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;
Loading
Loading