diff --git a/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/subject.js b/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/subject.js new file mode 100644 index 000000000000..a86616cd52dc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/subject.js @@ -0,0 +1,6 @@ +import { parameterize } from '@sentry/utils'; + +const x = 'first'; +const y = 'second'; + +Sentry.captureMessage(parameterize`This is a log statement with ${x} and ${y} params`); diff --git a/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts b/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts new file mode 100644 index 000000000000..4c948d439bff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture a parameterized representation of the message', async ({ getLocalTestPath, page }) => { + const bundle = process.env.PW_BUNDLE; + + if (bundle && bundle.startsWith('bundle_')) { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.logentry).toStrictEqual({ + message: 'This is a log statement with %s and %s params', + params: ['first', 'second'], + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts new file mode 100644 index 000000000000..db81bb18d331 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { parameterize } from '@sentry/utils'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', +}); + +const x = 'first'; +const y = 'second'; + +Sentry.captureMessage(parameterize`This is a log statement with ${x} and ${y} params`); diff --git a/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts new file mode 100644 index 000000000000..d9015987187f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts @@ -0,0 +1,13 @@ +import { TestEnv, assertSentryEvent } from '../../../../utils'; + +test('should capture a parameterized representation of the message', async () => { + const env = await TestEnv.init(__dirname); + const event = await env.getEnvelopeRequest(); + + assertSentryEvent(event[2], { + logentry: { + message: 'This is a log statement with %s and %s params', + params: ['first', 'second'], + }, + }); +}); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index c8d4e4ce2f08..14d4660b8482 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -7,6 +7,7 @@ import type { Event, EventHint, Options, + ParameterizedString, Severity, SeverityLevel, UserFeedback, @@ -84,7 +85,7 @@ export class BrowserClient extends BaseClient { * @inheritDoc */ public eventFromMessage( - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level: Severity | SeverityLevel = 'info', hint?: EventHint, diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 6955fbfa26fe..9e72fb288cb4 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -1,5 +1,14 @@ import { getClient } from '@sentry/core'; -import type { Event, EventHint, Exception, Severity, SeverityLevel, StackFrame, StackParser } from '@sentry/types'; +import type { + Event, + EventHint, + Exception, + ParameterizedString, + Severity, + SeverityLevel, + StackFrame, + StackParser, +} from '@sentry/types'; import { addExceptionMechanism, addExceptionTypeValue, @@ -9,6 +18,7 @@ import { isError, isErrorEvent, isEvent, + isParameterizedString, isPlainObject, normalizeToSize, resolvedSyncPromise, @@ -167,7 +177,7 @@ export function eventFromException( */ export function eventFromMessage( stackParser: StackParser, - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level: Severity | SeverityLevel = 'info', hint?: EventHint, @@ -264,23 +274,32 @@ export function eventFromUnknownInput( */ export function eventFromString( stackParser: StackParser, - input: string, + message: ParameterizedString, syntheticException?: Error, attachStacktrace?: boolean, ): Event { - const event: Event = { - message: input, - }; + const event: Event = {}; if (attachStacktrace && syntheticException) { const frames = parseStackFrames(stackParser, syntheticException); if (frames.length) { event.exception = { - values: [{ value: input, stacktrace: { frames } }], + values: [{ value: message, stacktrace: { frames } }], }; } } + if (isParameterizedString(message)) { + const { __sentry_template_string__, __sentry_template_values__ } = message; + + event.logentry = { + message: __sentry_template_string__, + params: __sentry_template_values__, + }; + return event; + } + + event.message = message; return event; } diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 75b736bbf803..628df591248d 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -19,6 +19,7 @@ import type { MetricBucketItem, MetricsAggregator, Outcome, + ParameterizedString, PropagationContext, SdkMetadata, Session, @@ -36,6 +37,7 @@ import { addItemToEnvelope, checkOrSetAlreadyCaught, createAttachmentEnvelopeItem, + isParameterizedString, isPlainObject, isPrimitive, isThenable, @@ -182,7 +184,7 @@ export abstract class BaseClient implements Client { * @inheritDoc */ public captureMessage( - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level?: Severity | SeverityLevel, hint?: EventHint, @@ -190,8 +192,10 @@ export abstract class BaseClient implements Client { ): string | undefined { let eventId: string | undefined = hint && hint.event_id; + const eventMessage = isParameterizedString(message) ? message : String(message); + const promisedEvent = isPrimitive(message) - ? this.eventFromMessage(String(message), level, hint) + ? this.eventFromMessage(eventMessage, level, hint) : this.eventFromException(message, hint); this._process( @@ -816,7 +820,7 @@ export abstract class BaseClient implements Client { * @inheritDoc */ public abstract eventFromMessage( - _message: string, + _message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation _level?: Severity | SeverityLevel, _hint?: EventHint, diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 68e1eb065d89..44b1eea5d388 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -6,6 +6,7 @@ import type { Event, EventHint, MonitorConfig, + ParameterizedString, SerializedCheckIn, Severity, SeverityLevel, @@ -63,7 +64,7 @@ export class ServerRuntimeClient< * @inheritDoc */ public eventFromMessage( - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level: Severity | SeverityLevel = 'info', hint?: EventHint, diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 3fe26f9d0bec..7cb1e08cecba 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -5,6 +5,7 @@ import type { EventHint, Integration, Outcome, + ParameterizedString, Session, Severity, SeverityLevel, @@ -76,7 +77,7 @@ export class TestClient extends BaseClient { } public eventFromMessage( - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level: Severity | SeverityLevel = 'info', ): PromiseLike { diff --git a/packages/replay/test/utils/TestClient.ts b/packages/replay/test/utils/TestClient.ts index ad39b82084a9..da131aec8fd2 100644 --- a/packages/replay/test/utils/TestClient.ts +++ b/packages/replay/test/utils/TestClient.ts @@ -1,5 +1,11 @@ import { BaseClient, createTransport, initAndBind } from '@sentry/core'; -import type { BrowserClientReplayOptions, ClientOptions, Event, SeverityLevel } from '@sentry/types'; +import type { + BrowserClientReplayOptions, + ClientOptions, + Event, + ParameterizedString, + SeverityLevel, +} from '@sentry/types'; import { resolvedSyncPromise } from '@sentry/utils'; export interface TestClientOptions extends ClientOptions, BrowserClientReplayOptions {} @@ -24,7 +30,7 @@ export class TestClient extends BaseClient { }); } - public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { + public eventFromMessage(message: ParameterizedString, level: SeverityLevel = 'info'): PromiseLike { return resolvedSyncPromise({ message, level }); } } diff --git a/packages/tracing-internal/test/utils/TestClient.ts b/packages/tracing-internal/test/utils/TestClient.ts index ad39b82084a9..da131aec8fd2 100644 --- a/packages/tracing-internal/test/utils/TestClient.ts +++ b/packages/tracing-internal/test/utils/TestClient.ts @@ -1,5 +1,11 @@ import { BaseClient, createTransport, initAndBind } from '@sentry/core'; -import type { BrowserClientReplayOptions, ClientOptions, Event, SeverityLevel } from '@sentry/types'; +import type { + BrowserClientReplayOptions, + ClientOptions, + Event, + ParameterizedString, + SeverityLevel, +} from '@sentry/types'; import { resolvedSyncPromise } from '@sentry/utils'; export interface TestClientOptions extends ClientOptions, BrowserClientReplayOptions {} @@ -24,7 +30,7 @@ export class TestClient extends BaseClient { }); } - public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { + public eventFromMessage(message: ParameterizedString, level: SeverityLevel = 'info'): PromiseLike { return resolvedSyncPromise({ message, level }); } } diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index bfb657d135fa..c9c37349306d 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -10,6 +10,7 @@ import type { FeedbackEvent } from './feedback'; import type { Integration, IntegrationClass } from './integration'; import type { MetricBucketItem } from './metrics'; import type { ClientOptions } from './options'; +import type { ParameterizedString } from './parameterize'; import type { Scope } from './scope'; import type { SdkMetadata } from './sdkmetadata'; import type { Session, SessionAggregates } from './session'; @@ -159,7 +160,7 @@ export interface Client { /** Creates an {@link Event} from primitive inputs to `captureMessage`. */ eventFromMessage( - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level?: Severity | SeverityLevel, hint?: EventHint, diff --git a/packages/types/src/event.ts b/packages/types/src/event.ts index f04386968280..b9e908371f1e 100644 --- a/packages/types/src/event.ts +++ b/packages/types/src/event.ts @@ -20,6 +20,10 @@ import type { User } from './user'; export interface Event { event_id?: string; message?: string; + logentry?: { + message?: string; + params?: string[]; + }; timestamp?: number; start_timestamp?: number; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 655962f592fc..a6d259870714 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -140,3 +140,4 @@ export type { export type { BrowserClientReplayOptions, BrowserClientProfilingOptions } from './browseroptions'; export type { CheckIn, MonitorConfig, FinishedCheckIn, InProgressCheckIn, SerializedCheckIn } from './checkin'; export type { MetricsAggregator, MetricBucketItem, MetricInstance } from './metrics'; +export type { ParameterizedString } from './parameterize'; diff --git a/packages/types/src/parameterize.ts b/packages/types/src/parameterize.ts new file mode 100644 index 000000000000..a94daa3684db --- /dev/null +++ b/packages/types/src/parameterize.ts @@ -0,0 +1,4 @@ +export type ParameterizedString = string & { + __sentry_template_string__?: string; + __sentry_template_values__?: string[]; +}; diff --git a/packages/utils/src/eventbuilder.ts b/packages/utils/src/eventbuilder.ts index 28b2d94b0c4f..2a65a6d014cf 100644 --- a/packages/utils/src/eventbuilder.ts +++ b/packages/utils/src/eventbuilder.ts @@ -6,13 +6,14 @@ import type { Extras, Hub, Mechanism, + ParameterizedString, Severity, SeverityLevel, StackFrame, StackParser, } from '@sentry/types'; -import { isError, isPlainObject } from './is'; +import { isError, isParameterizedString, isPlainObject } from './is'; import { addExceptionMechanism, addExceptionTypeValue } from './misc'; import { normalizeToSize } from './normalize'; import { extractExceptionKeysForMessage } from './object'; @@ -127,7 +128,7 @@ export function eventFromUnknownInput( */ export function eventFromMessage( stackParser: StackParser, - message: string, + message: ParameterizedString, // eslint-disable-next-line deprecation/deprecation level: Severity | SeverityLevel = 'info', hint?: EventHint, @@ -136,7 +137,6 @@ export function eventFromMessage( const event: Event = { event_id: hint && hint.event_id, level, - message, }; if (attachStacktrace && hint && hint.syntheticException) { @@ -153,5 +153,16 @@ export function eventFromMessage( } } + if (isParameterizedString(message)) { + const { __sentry_template_string__, __sentry_template_values__ } = message; + + event.logentry = { + message: __sentry_template_string__, + params: __sentry_template_values__, + }; + return event; + } + + event.message = message; return event; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d19991b7d401..5483f2aa7e41 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,6 +32,7 @@ export * from './url'; export * from './userIntegrations'; export * from './cache'; export * from './eventbuilder'; +export * from './parameterize'; export * from './anr'; export * from './lru'; export * from './buildPolyfills'; diff --git a/packages/utils/src/is.ts b/packages/utils/src/is.ts index 61a94053a265..12225b9c8b60 100644 --- a/packages/utils/src/is.ts +++ b/packages/utils/src/is.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import type { PolymorphicEvent, Primitive } from '@sentry/types'; +import type { ParameterizedString, PolymorphicEvent, Primitive } from '@sentry/types'; // eslint-disable-next-line @typescript-eslint/unbound-method const objectToString = Object.prototype.toString; @@ -78,6 +78,22 @@ export function isString(wat: unknown): wat is string { return isBuiltin(wat, 'String'); } +/** + * Checks whether given string is parameterized + * {@link isParameterizedString}. + * + * @param wat A value to be checked. + * @returns A boolean representing the result. + */ +export function isParameterizedString(wat: unknown): wat is ParameterizedString { + return ( + typeof wat === 'object' && + wat !== null && + '__sentry_template_string__' in wat && + '__sentry_template_values__' in wat + ); +} + /** * Checks whether given value is a primitive (undefined, null, number, boolean, string, bigint, symbol) * {@link isPrimitive}. @@ -86,7 +102,7 @@ export function isString(wat: unknown): wat is string { * @returns A boolean representing the result. */ export function isPrimitive(wat: unknown): wat is Primitive { - return wat === null || (typeof wat !== 'object' && typeof wat !== 'function'); + return wat === null || isParameterizedString(wat) || (typeof wat !== 'object' && typeof wat !== 'function'); } /** diff --git a/packages/utils/src/parameterize.ts b/packages/utils/src/parameterize.ts new file mode 100644 index 000000000000..2cfa63e92677 --- /dev/null +++ b/packages/utils/src/parameterize.ts @@ -0,0 +1,17 @@ +import type { ParameterizedString } from '@sentry/types'; + +/** + * Tagged template function which returns paramaterized representation of the message + * For example: parameterize`This is a log statement with ${x} and ${y} params`, would return: + * "__sentry_template_string__": "My raw message with interpreted strings like %s", + * "__sentry_template_values__": ["this"] + * @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 + */ +export function parameterize(strings: TemplateStringsArray, ...values: string[]): 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; +} diff --git a/packages/utils/test/parameterize.test.ts b/packages/utils/test/parameterize.test.ts new file mode 100644 index 000000000000..a199e0939271 --- /dev/null +++ b/packages/utils/test/parameterize.test.ts @@ -0,0 +1,27 @@ +import type { ParameterizedString } from '@sentry/types'; + +import { parameterize } from '../src/parameterize'; + +describe('parameterize()', () => { + test('works with empty string', () => { + const string = new String() as ParameterizedString; + string.__sentry_template_string__ = ''; + string.__sentry_template_values__ = []; + + const formatted = parameterize``; + expect(formatted.__sentry_template_string__).toEqual(''); + expect(formatted.__sentry_template_values__).toEqual([]); + }); + + test('works as expected with template literals', () => { + const x = 'first'; + const y = 'second'; + const string = new String() as ParameterizedString; + string.__sentry_template_string__ = 'This is a log statement with %s and %s params'; + string.__sentry_template_values__ = ['first', 'second']; + + const formatted = parameterize`This is a log statement with ${x} and ${y} params`; + expect(formatted.__sentry_template_string__).toEqual(string.__sentry_template_string__); + expect(formatted.__sentry_template_values__).toEqual(string.__sentry_template_values__); + }); +});