Skip to content

feat(node): Add logging public APIs to Node SDKs #15764

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 3 commits into from
Mar 24, 2025
Merged
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
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -127,6 +127,7 @@ export {
withScope,
zodErrorsIntegration,
profiler,
logger,
} from '@sentry/node';

export { init } from './server/sdk';
3 changes: 3 additions & 0 deletions packages/astro/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -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<boolean

export declare const Span: clientSdk.Span;

export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger;

export default sentryAstro;
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
@@ -113,6 +113,7 @@ export {
profiler,
amqplibIntegration,
vercelAIIntegration,
logger,
} from '@sentry/node';

export {
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
@@ -132,6 +132,7 @@ export {
profiler,
amqplibIntegration,
vercelAIIntegration,
logger,
} from '@sentry/node';

export {
13 changes: 13 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import type {
FeedbackEvent,
FetchBreadcrumbHint,
Integration,
Log,
MonitorConfig,
Outcome,
ParameterizedString,
@@ -621,6 +622,13 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
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<O extends ClientOptions = ClientOptions> {
*/
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()`.
*/
12 changes: 6 additions & 6 deletions packages/core/src/logs/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}

/**
68 changes: 68 additions & 0 deletions packages/core/src/server-runtime-client.ts
Original file line number Diff line number Diff line change
@@ -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<BaseTransportOptions> {
platform?: string;
@@ -33,6 +37,8 @@ export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportO
export class ServerRuntimeClient<
O extends ClientOptions & ServerRuntimeClientOptions = ServerRuntimeClientOptions,
> extends Client<O> {
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;
}
2 changes: 1 addition & 1 deletion packages/core/src/types-hoist/log.ts
Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@ export interface Log {
/**
* Arbitrary structured data that stores information about the log - e.g., userId: 100.
*/
attributes?: Record<string, string | number | boolean | Array<string | number | boolean>>;
attributes?: Record<string, unknown>;

/**
* The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug'
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
@@ -113,6 +113,7 @@ export {
amqplibIntegration,
childProcessIntegration,
vercelAIIntegration,
logger,
} from '@sentry/node';

export {
2 changes: 2 additions & 0 deletions packages/nextjs/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
4 changes: 4 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -150,3 +150,7 @@ export type {
User,
Span,
} from '@sentry/core';

import * as logger from './log';

export { logger };
223 changes: 223 additions & 0 deletions packages/node/src/log.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, 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);
}
130 changes: 130 additions & 0 deletions packages/node/test/log.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
});
});
});
3 changes: 3 additions & 0 deletions packages/nuxt/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions packages/react-router/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions packages/remix/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,8 @@ export declare const defaultStackParser: StackParser;

export declare function captureRemixServerException(err: unknown, name: string, request: Request): Promise<void>;

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`.
1 change: 1 addition & 0 deletions packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -112,6 +112,7 @@ export {
withMonitor,
withScope,
zodErrorsIntegration,
logger,
} from '@sentry/node';

// Keeping the `*` exports for backwards compatibility and types
2 changes: 2 additions & 0 deletions packages/solidstart/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -22,3 +22,5 @@ export declare const defaultStackParser: StackParser;
export declare function close(timeout?: number | undefined): PromiseLike<boolean>;
export declare function flush(timeout?: number | undefined): PromiseLike<boolean>;
export declare function lastEventId(): string | undefined;

export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger;
1 change: 1 addition & 0 deletions packages/solidstart/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -115,6 +115,7 @@ export {
withMonitor,
withScope,
zodErrorsIntegration,
logger,
} from '@sentry/node';

// We can still leave this for the carrier init and type exports
2 changes: 2 additions & 0 deletions packages/sveltekit/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -53,3 +53,5 @@ export declare function flush(timeout?: number | undefined): PromiseLike<boolean
export declare function lastEventId(): string | undefined;

export declare function trackComponent(options: clientSdk.TrackingOptions): ReturnType<typeof clientSdk.trackComponent>;

export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger;
1 change: 1 addition & 0 deletions packages/sveltekit/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -117,6 +117,7 @@ export {
withMonitor,
withScope,
zodErrorsIntegration,
logger,
} from '@sentry/node';

// We can still leave this for the carrier init and type exports
2 changes: 2 additions & 0 deletions packages/tanstackstart-react/src/index.types.ts
Original file line number Diff line number Diff line change
@@ -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;