diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 6f9647d0134e..6467affd841c 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -127,6 +127,7 @@ export { withScope, zodErrorsIntegration, profiler, + logger, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index eeadf11fa3d5..ec6713098f11 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -10,6 +10,7 @@ import type { NodeOptions } from '@sentry/node'; import type { Client, Integration, Options, StackParser } from '@sentry/core'; import type * as clientSdk from './index.client'; +import type * as serverSdk from './index.server'; import sentryAstro from './index.server'; /** Initializes Sentry Astro SDK */ @@ -26,4 +27,6 @@ export declare function flush(timeout?: number | undefined): PromiseLike { */ public on(hook: 'close', callback: () => void): () => void; + /** + * A hook that is called before a log is captured + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'beforeCaptureLog', callback: (log: Log) => void): () => void; + /** * Register a hook on this client. */ @@ -768,6 +776,11 @@ export abstract class Client { */ public emit(hook: 'close'): void; + /** + * Emit a hook event for client before capturing a log + */ + public emit(hook: 'beforeCaptureLog', log: Log): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/logs/index.ts b/packages/core/src/logs/index.ts index 6c6c9b580ee9..56de1b0bdc15 100644 --- a/packages/core/src/logs/index.ts +++ b/packages/core/src/logs/index.ts @@ -110,14 +110,14 @@ export function _INTERNAL_captureLog(log: Log, client = getClient(), scope = get const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client); if (logBuffer === undefined) { CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]); - // Every time we initialize a new log buffer, we start a new interval to flush the buffer - return; + } else { + logBuffer.push(serializedLog); + if (logBuffer.length > MAX_LOG_BUFFER_SIZE) { + _INTERNAL_flushLogsBuffer(client, logBuffer); + } } - logBuffer.push(serializedLog); - if (logBuffer.length > MAX_LOG_BUFFER_SIZE) { - _INTERNAL_flushLogsBuffer(client, logBuffer); - } + client.emit('beforeCaptureLog', log); } /** diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 61db792b901e..9f160d7ee25e 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -4,8 +4,10 @@ import type { ClientOptions, Event, EventHint, + Log, MonitorConfig, ParameterizedString, + Primitive, SerializedCheckIn, SeverityLevel, } from './types-hoist'; @@ -20,6 +22,8 @@ import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuil import { logger } from './utils-hoist/logger'; import { uuid4 } from './utils-hoist/misc'; import { resolvedSyncPromise } from './utils-hoist/syncpromise'; +import { _INTERNAL_flushLogsBuffer } from './logs'; +import { isPrimitive } from './utils-hoist'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -33,6 +37,8 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends Client { + private _logWeight: number; + /** * Creates a new Edge SDK instance. * @param options Configuration options for this SDK. @@ -42,6 +48,26 @@ export class ServerRuntimeClient< registerSpanErrorInstrumentation(); super(options); + + this._logWeight = 0; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const client = this; + this.on('flush', () => { + _INTERNAL_flushLogsBuffer(client); + }); + + this.on('beforeCaptureLog', log => { + client._logWeight += estimateLogSizeInBytes(log); + + // We flush the logs buffer if it exceeds 0.8 MB + // The log weight is a rough estimate, so we flush way before + // the payload gets too big. + if (client._logWeight > 800_000) { + _INTERNAL_flushLogsBuffer(client); + client._logWeight = 0; + } + }); } /** @@ -196,3 +222,45 @@ function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { } } } + +/** + * Estimate the size of a log in bytes. + * + * @param log - The log to estimate the size of. + * @returns The estimated size of the log in bytes. + */ +function estimateLogSizeInBytes(log: Log): number { + let weight = 0; + + // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. + if (log.message) { + weight += log.message.length * 2; + } + + if (log.attributes) { + Object.values(log.attributes).forEach(value => { + if (Array.isArray(value)) { + weight += value.length * estimatePrimitiveSizeInBytes(value[0]); + } else if (isPrimitive(value)) { + weight += estimatePrimitiveSizeInBytes(value); + } else { + // For objects values, we estimate the size of the object as 100 bytes + weight += 100; + } + }); + } + + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'number') { + return 8; + } else if (typeof value === 'boolean') { + return 4; + } + + return 0; +} diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index a313b493306c..45172c44adc0 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -41,7 +41,7 @@ export interface Log { /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: Record>; + attributes?: Record; /** * The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug' diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9505ef6dd248..83a18f62c6df 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, + logger, } from '@sentry/node'; export { diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 7b6173f9c8ea..f1b24da081f0 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -32,6 +32,8 @@ export declare const createReduxEnhancer: typeof clientSdk.createReduxEnhancer; export declare const showReportDialog: typeof clientSdk.showReportDialog; export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; + export { withSentryConfig } from './config'; /** diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bdc8d6405217..decfbd578c68 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -150,3 +150,7 @@ export type { User, Span, } from '@sentry/core'; + +import * as logger from './log'; + +export { logger }; diff --git a/packages/node/src/log.ts b/packages/node/src/log.ts new file mode 100644 index 000000000000..9bad4895ceb6 --- /dev/null +++ b/packages/node/src/log.ts @@ -0,0 +1,223 @@ +import { format } from 'node:util'; + +import type { LogSeverityLevel, Log } from '@sentry/core'; +import { _INTERNAL_captureLog } from '@sentry/core'; + +type CaptureLogArgs = + | [message: string, attributes?: Log['attributes']] + | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; + +/** + * 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. + */ +function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void { + const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args; + if (Array.isArray(paramsOrAttributes)) { + const attributes = { ...maybeAttributes }; + attributes['sentry.message.template'] = messageOrMessageTemplate; + paramsOrAttributes.forEach((param, index) => { + attributes[`sentry.message.param.${index}`] = param; + }); + const message = format(messageOrMessageTemplate, ...paramsOrAttributes); + _INTERNAL_captureLog({ level, message, attributes }); + } else { + _INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }); + } +} + +/** + * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.trace('Starting database connection', { + * database: 'users', + * connectionId: 'conn_123' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.trace('Database connection %s established for %s', + * ['successful', 'users'], + * { connectionId: 'conn_123' } + * ); + * ``` + */ +export function trace(...args: CaptureLogArgs): void { + captureLog('trace', ...args); +} + +/** + * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.debug('Cache miss for user profile', { + * userId: 'user_123', + * cacheKey: 'profile:user_123' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.debug('Cache %s for %s: %s', + * ['miss', 'user profile', 'key not found'], + * { userId: 'user_123' } + * ); + * ``` + */ +export function debug(...args: CaptureLogArgs): void { + captureLog('debug', ...args); +} + +/** + * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.info('User profile updated', { + * userId: 'user_123', + * updatedFields: ['email', 'preferences'] + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.info('User %s updated their %s', + * ['John Doe', 'profile settings'], + * { userId: 'user_123' } + * ); + * ``` + */ +export function info(...args: CaptureLogArgs): void { + captureLog('info', ...args); +} + +/** + * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.warn('Rate limit approaching', { + * endpoint: '/api/users', + * currentRate: '95/100', + * resetTime: '2024-03-20T10:00:00Z' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.warn('Rate limit %s for %s: %s', + * ['approaching', '/api/users', '95/100 requests'], + * { resetTime: '2024-03-20T10:00:00Z' } + * ); + * ``` + */ +export function warn(...args: CaptureLogArgs): void { + captureLog('warn', ...args); +} + +/** + * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.error('Failed to process payment', { + * orderId: 'order_123', + * errorCode: 'PAYMENT_FAILED', + * amount: 99.99 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.error('Payment processing failed for order %s: %s', + * ['order_123', 'insufficient funds'], + * { amount: 99.99 } + * ); + * ``` + */ +export function error(...args: CaptureLogArgs): void { + captureLog('error', ...args); +} + +/** + * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.fatal('Database connection pool exhausted', { + * database: 'users', + * activeConnections: 100, + * maxConnections: 100 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.fatal('Database %s: %s connections active', + * ['connection pool exhausted', '100/100'], + * { database: 'users' } + * ); + * ``` + */ +export function fatal(...args: CaptureLogArgs): void { + captureLog('fatal', ...args); +} + +/** + * @summary Capture a log with the `critical` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.critical('Service health check failed', { + * service: 'payment-gateway', + * status: 'DOWN', + * lastHealthy: '2024-03-20T09:55:00Z' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.critical('Service %s is %s', + * ['payment-gateway', 'DOWN'], + * { lastHealthy: '2024-03-20T09:55:00Z' } + * ); + * ``` + */ +export function critical(...args: CaptureLogArgs): void { + captureLog('critical', ...args); +} diff --git a/packages/node/test/log.test.ts b/packages/node/test/log.test.ts new file mode 100644 index 000000000000..4064d7c1f3f1 --- /dev/null +++ b/packages/node/test/log.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as sentryCore from '@sentry/core'; +import * as nodeLogger from '../src/log'; + +// Mock the core functions +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + _INTERNAL_captureLog: vi.fn(), + }; +}); + +describe('Node Logger', () => { + // Use the mocked function + const mockCaptureLog = vi.mocked(sentryCore._INTERNAL_captureLog); + + beforeEach(() => { + // Reset mocks + mockCaptureLog.mockClear(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Basic logging methods', () => { + it('should export all log methods', () => { + expect(nodeLogger.trace).toBeTypeOf('function'); + expect(nodeLogger.debug).toBeTypeOf('function'); + expect(nodeLogger.info).toBeTypeOf('function'); + expect(nodeLogger.warn).toBeTypeOf('function'); + expect(nodeLogger.error).toBeTypeOf('function'); + expect(nodeLogger.fatal).toBeTypeOf('function'); + expect(nodeLogger.critical).toBeTypeOf('function'); + }); + + it('should call _INTERNAL_captureLog with trace level', () => { + nodeLogger.trace('Test trace message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'trace', + message: 'Test trace message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with debug level', () => { + nodeLogger.debug('Test debug message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: 'Test debug message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with info level', () => { + nodeLogger.info('Test info message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test info message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with warn level', () => { + nodeLogger.warn('Test warn message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'warn', + message: 'Test warn message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with error level', () => { + nodeLogger.error('Test error message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'error', + message: 'Test error message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with fatal level', () => { + nodeLogger.fatal('Test fatal message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'fatal', + message: 'Test fatal message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with critical level', () => { + nodeLogger.critical('Test critical message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'critical', + message: 'Test critical message', + attributes: { key: 'value' }, + }); + }); + }); + + describe('Template string logging', () => { + it('should handle template strings with parameters', () => { + nodeLogger.info('Hello %s, your balance is %d', ['John', 100], { userId: 123 }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Hello John, your balance is 100', + attributes: { + userId: 123, + 'sentry.message.template': 'Hello %s, your balance is %d', + 'sentry.message.param.0': 'John', + 'sentry.message.param.1': 100, + }, + }); + }); + + it('should handle template strings without additional attributes', () => { + nodeLogger.debug('User %s logged in from %s', ['Alice', 'mobile']); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: 'User Alice logged in from mobile', + attributes: { + 'sentry.message.template': 'User %s logged in from %s', + 'sentry.message.param.0': 'Alice', + 'sentry.message.param.1': 'mobile', + }, + }); + }); + }); +}); diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index b1393a076029..24b21b8680ae 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -1,6 +1,7 @@ import type { Client, Integration, Options, StackParser } from '@sentry/core'; import type { SentryNuxtClientOptions, SentryNuxtServerOptions } from './common/types'; import type * as clientSdk from './index.client'; +import type * as serverSdk from './index.server'; // We export everything from both the client part of the SDK and from the server part. Some of the exports collide, // which is not allowed, unless we re-export the colliding exports in this file - which we do below. @@ -13,3 +14,5 @@ export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsInteg export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index 4d7fb425f5ee..cf62bf717794 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -15,3 +15,5 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const defaultStackParser: StackParser; export declare const getDefaultIntegrations: (options: Options) => Integration[]; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 763a2747f69e..2d1ae40da8e5 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -21,6 +21,8 @@ export declare const defaultStackParser: StackParser; export declare function captureRemixServerException(err: unknown, name: string, request: Request): Promise; +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; + // This variable is not a runtime variable but just a type to tell typescript that the methods below can either come // from the client SDK or from the server SDK. TypeScript is smart enough to understand that these resolve to the same // methods from `@sentry/core`. diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 4160a871d165..7809455ce3fa 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -112,6 +112,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + logger, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 54a5ec6d6a3c..d243bd371241 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -22,3 +22,5 @@ export declare const defaultStackParser: StackParser; export declare function close(timeout?: number | undefined): PromiseLike; export declare function flush(timeout?: number | undefined): PromiseLike; export declare function lastEventId(): string | undefined; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 948c3c746d0c..a36b7fbfaad1 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -115,6 +115,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + logger, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index bf2edbfb0a0f..161e7098de11 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -53,3 +53,5 @@ export declare function flush(timeout?: number | undefined): PromiseLike; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f8844c1e264d..9df3ddd688c8 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -117,6 +117,7 @@ export { withMonitor, withScope, zodErrorsIntegration, + logger, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 84a987788d17..0e3bbca37bf9 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -25,3 +25,5 @@ export declare const ErrorBoundary: typeof clientSdk.ErrorBoundary; export declare const createReduxEnhancer: typeof clientSdk.createReduxEnhancer; export declare const showReportDialog: typeof clientSdk.showReportDialog; export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger;