diff --git a/.size-limit.js b/.size-limit.js index ffa69d850947..1ff20d60a62e 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,14 +8,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init'), gzip: true, - limit: '24 KB', + limit: '25 KB', }, { name: '@sentry/browser - with treeshaking flags', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init'), gzip: true, - limit: '24.1 KB', + limit: '24.5 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); @@ -40,14 +40,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '37.5 KB', + limit: '38 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75.2 KB', + limit: '77 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -79,7 +79,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '80 KB', + limit: '82 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -100,14 +100,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: '@sentry/browser (incl. FeedbackAsync)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '34 KB', + limit: '34.5 KB', }, // React SDK (ESM) { @@ -210,7 +210,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '41 KB', + limit: '43 KB', }, // SvelteKit SDK (ESM) { @@ -219,7 +219,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '38 KB', + limit: '39 KB', }, // Node SDK (ESM) { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 6f9647d0134e..6364849d2b16 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -127,6 +127,7 @@ export { withScope, zodErrorsIntegration, profiler, + _experiment_log, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 51848530712b..f3a1d2a3e476 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -113,6 +113,7 @@ export { profiler, amqplibIntegration, vercelAIIntegration, + _experiment_log, } from '@sentry/node'; export { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d034330b6283..07f772da5db9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -10,6 +10,7 @@ export { extraErrorDataIntegration, rewriteFramesIntegration, captureFeedback, + _experiment_log, } from '@sentry/core'; export { replayIntegration, getReplay } from '@sentry-internal/replay'; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 52b3d9fa4c42..9714cfecfd6c 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -132,6 +132,7 @@ export { profiler, amqplibIntegration, vercelAIIntegration, + _experiment_log, } from '@sentry/node'; export { diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 05fd40fb4c96..2cfbca9bebc8 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -85,6 +85,7 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + _experiment_log, } from '@sentry/core'; export { withSentry } from './handler'; diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 4854ee86efb8..b748375c7165 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -1,5 +1,6 @@ import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; +import { captureLog, sendLog } from './log'; import type { CaptureContext } from './scope'; import { closeSession, makeSession, updateSession } from './session'; import type { @@ -22,6 +23,7 @@ import { logger } from './utils-hoist/logger'; import { uuid4 } from './utils-hoist/misc'; import { timestampInSeconds } from './utils-hoist/time'; import { GLOBAL_OBJ } from './utils-hoist/worldwide'; +import { parameterizeStringTemplate } from './utils/parameterize'; import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; import { parseEventHintOrCaptureContext } from './utils/prepareEvent'; @@ -334,3 +336,117 @@ export function captureSession(end: boolean = false): void { // only send the update _sendSessionUpdate(); } + +/** + * A namespace for experimental logging functions. + * + * @experimental Will be removed in future versions. Use with caution. + */ +export const _experiment_log = { + /** + * A utility to record a log with level 'TRACE' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { trace, fmt } = Sentry._experiment_log; + * trace(fmt`user ${username} just bought ${item}!`); + * ``` + */ + trace: sendLog('trace'), + /** + * A utility to record a log with level 'DEBUG' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { debug, fmt } = Sentry._experiment_log; + * debug(fmt`user ${username} just bought ${item}!`); + * ``` + */ + debug: sendLog('debug'), + /** + * A utility to record a log with level 'INFO' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { info, fmt } = Sentry._experiment_log; + * info(fmt`user ${username} just bought ${item}!`); + * ``` + */ + info: sendLog('info'), + /** + * A utility to record a log with level 'INFO' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { log, fmt } = Sentry._experiment_log; + * log(fmt`user ${username} just bought ${item}!`); + * ``` + */ + log: sendLog('info', 10), + /** + * A utility to record a log with level 'ERROR' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { error, fmt } = Sentry._experiment_log; + * error(fmt`user ${username} just bought ${item}!`); + * ``` + */ + error: sendLog('error'), + /** + * A utility to record a log with level 'WARN' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { warn, fmt } = Sentry._experiment_log; + * warn(fmt`user ${username} just bought ${item}!`); + * ``` + */ + warn: sendLog('warn'), + /** + * A utility to record a log with level 'FATAL' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * + * @example + * ```js + * const { fatal, fmt } = Sentry._experiment_log; + * fatal(fmt`user ${username} just bought ${item}!`); + * ``` + */ + fatal: sendLog('fatal'), + + /** + * Tagged template function which returns parameterized representation of the message + * + * @example + * ```js + * Sentry._experiment_log.fmt`This is a log statement with ${x} and ${y} params` + * ``` + */ + fmt: parameterizeStringTemplate, + + /** + * A flexible utility to record a log with a custom level and send it to sentry. + * + * You can optionally pass in custom attributes and a custom severity number to be attached to the log. + * + * @example + * ```js + * Sentry._experiment_log.emit({ level: 'info', message: Sentry._experiment_log.fmt`user ${username }just bought ${item}`, attributes: { extra: 123 } }) + * ``` + */ + emit: captureLog, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35bfc35bc603..859f98837b8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { endSession, captureSession, addEventProcessor, + _experiment_log, } from './exports'; export { getCurrentScope, diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 000000000000..58798e6ff256 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,225 @@ +import type { Client } from './client'; +import { getClient, getCurrentScope, getIsolationScope } from './currentScopes'; +import { DEBUG_BUILD } from './debug-build'; +import type { Scope } from './scope'; +import { getDynamicSamplingContextFromScope } from './tracing'; +import type { ParameterizedString } from './types-hoist'; +import type { DynamicSamplingContext, LogEnvelope, LogItem } from './types-hoist/envelope'; +import type { Log, LogAttribute, LogSeverityLevel } from './types-hoist/log'; +import { createEnvelope, dropUndefinedKeys, dsnToString, isParameterizedString, logger } from './utils-hoist'; +import { getActiveSpan } from './utils/spanUtils'; + +const LOG_BUFFER_MAX_LENGTH = 25; + +const GLOBAL_LOG_BUFFER: Log[] = []; +let globalLogBufferIndex = 0; + +let isFlushingLogs = false; + +const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial> = { + trace: 1, + debug: 5, + info: 9, + log: 10, + warn: 13, + error: 17, + fatal: 21, +}; + +/** + * Creates envelope item for a single log + */ +export function createLogEnvelopeItem(log: Log): LogItem { + const headers: LogItem[0] = { + type: 'otel_log', + }; + + return [headers, log]; +} + +/** + * Records a log and sends it to sentry. + * + * Logs represent a message (and optionally some structured data) which provide context for a trace or error. + * Ex: sentry.addLog({level: 'warning', message: `user ${user} just bought ${item}`, attributes: {user, item}} + * + * @params log - the log object which will be sent + */ +function createLogEnvelope(logs: Log[], client: Client, scope: Scope): LogEnvelope { + const dsc = getDynamicSamplingContextFromScope(client, scope); + + const dsn = client.getDsn(); + + const headers: LogEnvelope[0] = { + trace: dropUndefinedKeys(dsc) as DynamicSamplingContext, + ...(dsn ? { dsn: dsnToString(dsn) } : {}), + }; + + return createEnvelope(headers, logs.map(createLogEnvelopeItem)); +} + +function valueToAttribute(key: string, value: unknown): LogAttribute { + switch (typeof value) { + case 'number': + return { + key, + value: { doubleValue: value }, + }; + case 'boolean': + return { + key, + value: { boolValue: value }, + }; + case 'string': + return { + key, + value: { stringValue: value }, + }; + default: + return { + key, + value: { stringValue: JSON.stringify(value) ?? '' }, + }; + } +} + +function addToLogBuffer(client: Client, log: Log, scope: Scope, isolationScope: Scope): void { + function sendLogs(flushedLogs: Log[]): void { + const envelope = createLogEnvelope(flushedLogs, client, scope); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + void client.sendEnvelope(envelope); + } + + if (GLOBAL_LOG_BUFFER.length >= LOG_BUFFER_MAX_LENGTH) { + const beforeSendLogs = client.getOptions()?._experiments?.beforeSendLogs; + if(!beforeSendLogs || beforeSendLogs?.(GLOBAL_LOG_BUFFER, isolationScope.lastEventId())) { + sendLogs(GLOBAL_LOG_BUFFER); + GLOBAL_LOG_BUFFER.length = 0; + globalLogBufferIndex = 0; + } else { + // we should not send the logs buffer, evict a single log to make space for this one. + GLOBAL_LOG_BUFFER[globalLogBufferIndex] = log; + globalLogBufferIndex = (globalLogBufferIndex + 1) % LOG_BUFFER_MAX_LENGTH; + } + } else { + GLOBAL_LOG_BUFFER.push(log); + } + + // this is the first time logs have been enabled, let's kick off an interval to flush them + // we should only do this once. + if (!isFlushingLogs) { + const tryFlushLogs = (): void => { + const beforeSendLogs = client.getOptions()?._experiments?.beforeSendLogs; + if (!beforeSendLogs || beforeSendLogs?.(GLOBAL_LOG_BUFFER, isolationScope.lastEventId())) { + sendLogs(GLOBAL_LOG_BUFFER); + GLOBAL_LOG_BUFFER.length = 0; + globalLogBufferIndex = 0; + } + } + client.on('flush', () => { + tryFlushLogs(); + }); + + const flushTimer = setInterval(() => { + tryFlushLogs(); + }, 5000); + + // We need to unref the timer in node.js, otherwise the node process never exit. + if (typeof flushTimer !== 'number' && flushTimer.unref) { + flushTimer.unref(); + } + } + isFlushingLogs = true; +} + +/** + * A utility function to be able to create methods like Sentry.info(...). + */ +export function sendLog( + level: LogSeverityLevel, + severityNumber?: number, +): (message: ParameterizedString | string, customAttributes?: Record) => void { + return (message: ParameterizedString | string, attributes: Record = {}): void => + captureLog({ level, message, attributes, severityNumber }); +} + +/** + * Sends a log to Sentry. + */ +export function captureLog({ + level, + message, + attributes, + severityNumber, +}: { + level: LogSeverityLevel; + message: ParameterizedString | string; + attributes?: Record; + severityNumber?: number; +}): void { + const client = getClient(); + + if (!client) { + DEBUG_BUILD && logger.warn('No client available, log will not be captured.'); + return; + } + + if (!client.getOptions()._experiments?.enableLogs) { + DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.'); + return; + } + + const { release, environment } = client.getOptions(); + + const logAttributes = { + ...attributes, + }; + + if (isParameterizedString(message)) { + const { __sentry_template_string__ = '', __sentry_template_values__ = [] } = message; + if (__sentry_template_string__) { + logAttributes['sentry.message.template'] = __sentry_template_string__; + __sentry_template_values__.forEach((value, index) => { + logAttributes[`sentry.message.parameters.${index}`] = value; + }); + } + } + + const span = getActiveSpan(); + if (span) { + logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId; + } + + if (release) { + logAttributes['sentry.release'] = release; + } + + if (environment) { + logAttributes['sentry.environment'] = environment; + } + + const scope = getCurrentScope(); + const isolationScope = getIsolationScope(); + + const finalAttributes = Object.entries(logAttributes).map(([key, value]) => + valueToAttribute(key, value), + ); + + const log: Log = { + severityText: level, + body: { + stringValue: message, + }, + attributes: finalAttributes, + timeUnixNano: `${new Date().getTime().toString()}000000`, + traceId: scope.getPropagationContext().traceId, + severityNumber, + }; + + const maybeSeverityNumber = SEVERITY_TEXT_TO_SEVERITY_NUMBER[level]; + if (maybeSeverityNumber !== undefined && log.severityNumber === undefined) { + log.severityNumber = maybeSeverityNumber; + } + + addToLogBuffer(client, log, scope, isolationScope); +} diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 8e52b32eacf7..d0474b959fa9 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -182,7 +182,12 @@ export interface ClientOptions = string & { __sentry_template_string__?: string; - __sentry_template_values__?: string[]; + __sentry_template_values__?: Values; }; diff --git a/packages/core/src/utils/parameterize.ts b/packages/core/src/utils/parameterize.ts index 392f4b70b444..5a9cb29d6aeb 100644 --- a/packages/core/src/utils/parameterize.ts +++ b/packages/core/src/utils/parameterize.ts @@ -15,3 +15,22 @@ export function parameterize(strings: TemplateStringsArray, ...values: string[]) formatted.__sentry_template_values__ = values; return formatted; } + +/** + * Tagged template function which returns parameterized representation of the message + * 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 + */ +export function parameterizeStringTemplate( + 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; +} diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index a906197b40c2..c2fad35f5e2b 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -83,6 +83,7 @@ export { spanToTraceHeader, spanToBaggageHeader, updateSpanName, + _experiment_log, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/eslint-config-sdk/src/base.js b/packages/eslint-config-sdk/src/base.js index 8c11f26dd925..0b513b7316ba 100644 --- a/packages/eslint-config-sdk/src/base.js +++ b/packages/eslint-config-sdk/src/base.js @@ -84,7 +84,7 @@ module.exports = { // Make sure all expressions are used. Turned off in tests // Must disable base rule to prevent false positives 'no-unused-expressions': 'off', - '@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true }], + '@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true, allowTaggedTemplates: true }], // Make sure Promises are handled appropriately '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: false }], diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9505ef6dd248..b0c8cb572744 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -113,6 +113,7 @@ export { amqplibIntegration, childProcessIntegration, vercelAIIntegration, + _experiment_log, } from '@sentry/node'; export { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bdc8d6405217..99de94d7c3b2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -130,6 +130,7 @@ export { updateSpanName, zodErrorsIntegration, profiler, + _experiment_log, } from '@sentry/core'; export type { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 4160a871d165..af0cfe09be9a 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -112,6 +112,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + _experiment_log, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 948c3c746d0c..877bd4f312c2 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -115,6 +115,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + _experiment_log, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f8844c1e264d..75fe460bc841 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -117,6 +117,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + _experiment_log, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index eb6429c441fa..fef448433c29 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -84,6 +84,7 @@ export { spanToJSON, spanToTraceHeader, spanToBaggageHeader, + _experiment_log, } from '@sentry/core'; export { VercelEdgeClient } from './client';