From 94b5af5d08a9b65cddac6d30207eac8378060244 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 26 May 2025 16:05:04 +0200 Subject: [PATCH 1/2] feat: Add structured logging support --- src/common/ipc.ts | 7 +- src/main/ipc.ts | 15 ++- src/preload/index.ts | 2 + src/renderer/index.ts | 5 +- src/renderer/ipc.ts | 11 ++- src/renderer/log.ts | 210 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/renderer/log.ts diff --git a/src/common/ipc.ts b/src/common/ipc.ts index b637d9634..9ca4ad24b 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -1,3 +1,5 @@ +import { SerializedLog } from '@sentry/core'; + /** Ways to communicate between the renderer and main process */ export enum IPCMode { /** Configures Electron IPC to receive messages from renderers */ @@ -24,8 +26,8 @@ export enum IPCChannel { ENVELOPE = 'sentry-electron.envelope', /** IPC to pass renderer status updates */ STATUS = 'sentry-electron.status', - /** IPC to pass renderer metric additions to the main process */ - ADD_METRIC = 'sentry-electron.add-metric', + /** IPC to pass structured log messages */ + STRUCTURED_LOG = 'sentry-electron.structured-log', } export interface RendererProcessAnrOptions { @@ -59,6 +61,7 @@ export interface IPCInterface { sendScope: (scope: string) => void; sendEnvelope: (evn: Uint8Array | string) => void; sendStatus: (state: RendererStatus) => void; + sendStructuredLog: (log: SerializedLog) => void; } export const RENDERER_ID_HEADER = 'sentry-electron-renderer-id'; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fcffed26b..4f6ce0a88 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,4 +1,14 @@ -import { Attachment, Client, DynamicSamplingContext, Event, logger, parseEnvelope, ScopeData } from '@sentry/core'; +import { + _INTERNAL_captureSerializedLog, + Attachment, + Client, + DynamicSamplingContext, + Event, + logger, + parseEnvelope, + ScopeData, + SerializedLog, +} from '@sentry/core'; import { captureEvent, getClient, getCurrentScope } from '@sentry/node'; import { app, ipcMain, protocol, WebContents, webContents } from 'electron'; import { eventFromEnvelope } from '../common/envelope'; @@ -189,6 +199,8 @@ function configureProtocol(client: Client, options: ElectronMainOptionsInternal) handleScope(options, data.toString()); } else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.ENVELOPE}`) && data) { handleEnvelope(client, options, data, getWebContents()); + } else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.STRUCTURED_LOG}`) && data) { + _INTERNAL_captureSerializedLog(client, JSON.parse(data.toString()) as SerializedLog); } else if ( rendererStatusChanged && request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.STATUS}`) && @@ -232,6 +244,7 @@ function configureClassic(client: Client, options: ElectronMainOptionsInternal): ipcMain.on(IPCChannel.ENVELOPE, ({ sender }, env: Uint8Array | string) => handleEnvelope(client, options, env, sender), ); + ipcMain.on(IPCChannel.STRUCTURED_LOG, (_, log: SerializedLog) => _INTERNAL_captureSerializedLog(client, log)); const rendererStatusChanged = createRendererAnrStatusHandler(client); if (rendererStatusChanged) { diff --git a/src/preload/index.ts b/src/preload/index.ts index c780ace5b..0f6b7207e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ * This preload script may be used with sandbox mode enabled which means regular require is not available. */ +import { SerializedLog } from '@sentry/core'; import { contextBridge, ipcRenderer } from 'electron'; import { IPCChannel, RendererStatus } from '../common/ipc'; @@ -15,6 +16,7 @@ if (window.__SENTRY_IPC__) { sendScope: (scopeJson: string) => ipcRenderer.send(IPCChannel.SCOPE, scopeJson), sendEnvelope: (envelope: Uint8Array | string) => ipcRenderer.send(IPCChannel.ENVELOPE, envelope), sendStatus: (status: RendererStatus) => ipcRenderer.send(IPCChannel.STATUS, status), + sendStructuredLog: (log: SerializedLog) => ipcRenderer.send(IPCChannel.STRUCTURED_LOG, log), }; // eslint-disable-next-line no-restricted-globals diff --git a/src/renderer/index.ts b/src/renderer/index.ts index b923c729a..1243ad13e 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,3 +1,7 @@ +import * as logger from './log'; + +export { logger }; + export type { Breadcrumb, BreadcrumbHint, @@ -67,7 +71,6 @@ export { instrumentSupabaseClient, isInitialized, lastEventId, - logger, linkedErrorsIntegration, moduleMetadataIntegration, onLoad, diff --git a/src/renderer/ipc.ts b/src/renderer/ipc.ts index 3894c3c7c..b60182eaf 100644 --- a/src/renderer/ipc.ts +++ b/src/renderer/ipc.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-globals */ /* eslint-disable no-console */ -import { logger, uuid4 } from '@sentry/core'; +import { logger, SerializedLog, uuid4 } from '@sentry/core'; import { IPCChannel, IPCInterface, PROTOCOL_SCHEME, RENDERER_ID_HEADER, RendererStatus } from '../common/ipc'; function buildUrl(channel: IPCChannel): string { @@ -46,6 +46,15 @@ function getImplementation(): IPCInterface { // ignore }); }, + sendStructuredLog: (log: SerializedLog) => { + fetch(buildUrl(IPCChannel.STRUCTURED_LOG), { + method: 'POST', + body: JSON.stringify(log), + headers, + }).catch(() => { + // ignore + }); + }, }; } } diff --git a/src/renderer/log.ts b/src/renderer/log.ts new file mode 100644 index 000000000..9cc7fa99b --- /dev/null +++ b/src/renderer/log.ts @@ -0,0 +1,210 @@ +import { + _INTERNAL_captureLog, + getClient, + getCurrentScope, + Log, + LogSeverityLevel, + ParameterizedString, + SerializedLog, +} from '@sentry/core'; +import { getIPC } from './ipc'; + +function captureLog( + level: LogSeverityLevel, + message: ParameterizedString, + attributes?: Log['attributes'], + severityNumber?: Log['severityNumber'], +): void { + _INTERNAL_captureLog( + { level, message, attributes, severityNumber }, + getClient(), + getCurrentScope(), + (_: unknown, log: SerializedLog) => getIPC().sendStructuredLog(log), + ); +} + +/** + * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('trace', message, attributes); +} + +/** + * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('debug', message, attributes); +} + +/** + * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('info', message, attributes); +} + +/** + * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('warn', message, attributes); +} + +/** + * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('error', message, attributes); +} + +/** + * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled in the Electron main process. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. + * + * @example + * + * ``` + * 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: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('fatal', message, attributes); +} + +export { fmt } from '@sentry/core'; From 71f95142dff32e91a6214f6b45232d08fe694141 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 29 May 2025 14:19:47 +0100 Subject: [PATCH 2/2] Add test --- src/main/ipc.ts | 22 ++++++- src/main/sdk.ts | 9 +++ .../other/renderer-error/package.json | 9 +++ .../other/renderer-error/src/index.html | 26 +++++++++ .../other/renderer-error/src/main.js | 32 +++++++++++ .../test-apps/other/renderer-error/test.ts | 57 +++++++++++++++++++ 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 test/e2e/test-apps/other/renderer-error/package.json create mode 100644 test/e2e/test-apps/other/renderer-error/src/index.html create mode 100644 test/e2e/test-apps/other/renderer-error/src/main.js create mode 100644 test/e2e/test-apps/other/renderer-error/test.ts diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4f6ce0a88..cdc66dc0c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -19,6 +19,7 @@ import { rendererProfileFromIpc } from './integrations/renderer-profiling'; import { mergeEvents } from './merge'; import { normalizeReplayEnvelope } from './normalize'; import { ElectronMainOptionsInternal } from './sdk'; +import { SDK_VERSION } from './version'; let KNOWN_RENDERERS: Set | undefined; let WINDOW_ID_TO_WEB_CONTENTS: Map | undefined; @@ -164,6 +165,23 @@ function handleScope(options: ElectronMainOptionsInternal, jsonScope: string): v } } +function handleLogFromRenderer(client: Client, options: ElectronMainOptionsInternal, log: SerializedLog): void { + log.attributes = log.attributes || {}; + + if (options.release) { + log.attributes['sentry.release'] = { value: options.release, type: 'string' }; + } + + if (options.environment) { + log.attributes['sentry.environment'] = { value: options.environment, type: 'string' }; + } + + log.attributes['sentry.sdk.name'] = { value: 'sentry.javascript.electron', type: 'string' }; + log.attributes['sentry.sdk.version'] = { value: SDK_VERSION, type: 'string' }; + + _INTERNAL_captureSerializedLog(client, log); +} + /** Enables Electron protocol handling */ function configureProtocol(client: Client, options: ElectronMainOptionsInternal): void { if (app.isReady()) { @@ -200,7 +218,7 @@ function configureProtocol(client: Client, options: ElectronMainOptionsInternal) } else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.ENVELOPE}`) && data) { handleEnvelope(client, options, data, getWebContents()); } else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.STRUCTURED_LOG}`) && data) { - _INTERNAL_captureSerializedLog(client, JSON.parse(data.toString()) as SerializedLog); + handleLogFromRenderer(client, options, JSON.parse(data.toString())); } else if ( rendererStatusChanged && request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.STATUS}`) && @@ -244,7 +262,7 @@ function configureClassic(client: Client, options: ElectronMainOptionsInternal): ipcMain.on(IPCChannel.ENVELOPE, ({ sender }, env: Uint8Array | string) => handleEnvelope(client, options, env, sender), ); - ipcMain.on(IPCChannel.STRUCTURED_LOG, (_, log: SerializedLog) => _INTERNAL_captureSerializedLog(client, log)); + ipcMain.on(IPCChannel.STRUCTURED_LOG, (_, log: SerializedLog) => handleLogFromRenderer(client, options, log)); const rendererStatusChanged = createRendererAnrStatusHandler(client); if (rendererStatusChanged) { diff --git a/src/main/sdk.ts b/src/main/sdk.ts index 1a69e92dd..5648a0671 100644 --- a/src/main/sdk.ts +++ b/src/main/sdk.ts @@ -184,6 +184,15 @@ export function init(userOptions: ElectronMainOptions): void { const client = new NodeClient(options); + if (options._experiments?.enableLogs) { + // In Electron we don't want to capture the hostname in log attributes + client.on('beforeCaptureLog', (log) => { + if (log.attributes?.['server.address']) { + delete log.attributes['server.address']; + } + }); + } + if (options.sendDefaultPii === true) { client.on('postprocessEvent', addAutoIpAddressToUser); client.on('beforeSendSession', addAutoIpAddressToSession); diff --git a/test/e2e/test-apps/other/renderer-error/package.json b/test/e2e/test-apps/other/renderer-error/package.json new file mode 100644 index 000000000..6e4176a6f --- /dev/null +++ b/test/e2e/test-apps/other/renderer-error/package.json @@ -0,0 +1,9 @@ +{ + "name": "javascript-logs", + "description": "JavaScript Structural Logging", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "5.6.0" + } +} diff --git a/test/e2e/test-apps/other/renderer-error/src/index.html b/test/e2e/test-apps/other/renderer-error/src/index.html new file mode 100644 index 000000000..486b11630 --- /dev/null +++ b/test/e2e/test-apps/other/renderer-error/src/index.html @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/other/renderer-error/src/main.js b/test/e2e/test-apps/other/renderer-error/src/main.js new file mode 100644 index 000000000..edae63772 --- /dev/null +++ b/test/e2e/test-apps/other/renderer-error/src/main.js @@ -0,0 +1,32 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init, logger } = require('@sentry/electron/main'); + +init({ + dsn: '__DSN__', + debug: true, + _experiments: { enableLogs: true }, + onFatalError: () => {}, +}); + +app.on('ready', () => { + logger.info('User profile updated', { + userId: 'user_123', + updatedFields: ['email', 'preferences'], + }); + + const mainWindow = new BrowserWindow({ + show: false, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); +}); + +setTimeout(() => { + app.quit(); +}, 2000); diff --git a/test/e2e/test-apps/other/renderer-error/test.ts b/test/e2e/test-apps/other/renderer-error/test.ts new file mode 100644 index 000000000..61a79a958 --- /dev/null +++ b/test/e2e/test-apps/other/renderer-error/test.ts @@ -0,0 +1,57 @@ +import { expect } from 'vitest'; +import { SDK_VERSION } from '../../../../../src/main/version'; +import { electronTestRunner, UUID_MATCHER } from '../../..'; + +electronTestRunner(__dirname, async (ctx) => { + await ctx + .expect({ + envelope: [ + { sdk: { name: 'sentry.javascript.electron', version: SDK_VERSION } }, + [ + [ + { + type: 'log', + item_count: 2, + content_type: 'application/vnd.sentry.items.log+json', + }, + { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'User profile updated', + trace_id: UUID_MATCHER, + severity_number: 9, + attributes: { + userId: { value: 'user_123', type: 'string' }, + updatedFields: { value: '["email","preferences"]', type: 'string' }, + 'sentry.release': { value: 'javascript-logs@1.0.0', type: 'string' }, + 'sentry.environment': { value: 'development', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.electron', type: 'string' }, + 'sentry.sdk.version': { value: SDK_VERSION, type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'trace', + body: 'User clicked submit button', + trace_id: UUID_MATCHER, + severity_number: 1, + attributes: { + buttonId: { value: 'submit-form', type: 'string' }, + formId: { value: 'user-profile', type: 'string' }, + timestamp: { value: expect.any(Number), type: 'integer' }, + 'sentry.release': { value: 'javascript-logs@1.0.0', type: 'string' }, + 'sentry.environment': { value: 'development', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.electron', type: 'string' }, + 'sentry.sdk.version': { value: SDK_VERSION, type: 'string' }, + }, + }, + ], + }, + ], + ], + ], + }) + .run(); +});