diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 8ee01433..42f9a228 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -1,4 +1,4 @@ -import { SerializedLog } from '@sentry/core'; +import { SerializedLog, SerializedMetric } from '@sentry/core'; /** Ways to communicate between the renderer and main process */ export enum IPCMode { @@ -25,7 +25,9 @@ export type Channel = /** IPC to pass renderer status updates */ | 'status' /** IPC to pass structured log messages */ - | 'structured-log'; + | 'structured-log' + /** IPC to pass metric data */ + | 'metric'; export interface IpcUtils { createUrl: (channel: Channel) => string; @@ -85,6 +87,7 @@ export interface IPCInterface { sendEnvelope: (evn: Uint8Array | string) => void; sendStatus: (state: RendererStatus) => void; sendStructuredLog: (log: SerializedLog) => void; + sendMetric: (metric: SerializedMetric) => void; } export const RENDERER_ID_HEADER = 'sentry-electron-renderer-id'; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 803bcd49..a43dd81a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,8 @@ import { + type SerializedLog, + type SerializedMetric, _INTERNAL_captureSerializedLog, + _INTERNAL_captureSerializedMetric, Attachment, Client, debug, @@ -7,7 +10,6 @@ import { Event, parseEnvelope, ScopeData, - SerializedLog, } from '@sentry/core'; import { captureEvent, getClient, getCurrentScope } from '@sentry/node'; import { app, ipcMain, protocol, WebContents, webContents } from 'electron'; @@ -161,50 +163,70 @@ function handleScope(options: ElectronMainOptionsInternal, jsonScope: string): v } } -function handleLogFromRenderer( +function handleAttributes( client: Client, options: ElectronMainOptionsInternal, - log: SerializedLog, contents: WebContents | undefined, -): void { + maybeAttributes?: SerializedLog['attributes'], +): SerializedLog['attributes'] { const process = contents ? options?.getRendererName?.(contents) || 'renderer' : 'renderer'; - log.attributes = log.attributes || {}; + const attributes: SerializedLog['attributes'] = maybeAttributes || {}; if (options.release) { - log.attributes['sentry.release'] = { value: options.release, type: 'string' }; + attributes['sentry.release'] = { value: options.release, type: 'string' }; } if (options.environment) { - log.attributes['sentry.environment'] = { value: options.environment, type: 'string' }; + 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' }; + attributes['sentry.sdk.name'] = { value: 'sentry.javascript.electron', type: 'string' }; + attributes['sentry.sdk.version'] = { value: SDK_VERSION, type: 'string' }; - log.attributes['electron.process'] = { value: process, type: 'string' }; + attributes['electron.process'] = { value: process, type: 'string' }; const osDeviceAttributes = getOsDeviceLogAttributes(client); if (osDeviceAttributes['os.name']) { - log.attributes['os.name'] = { value: osDeviceAttributes['os.name'], type: 'string' }; + attributes['os.name'] = { value: osDeviceAttributes['os.name'], type: 'string' }; } if (osDeviceAttributes['os.version']) { - log.attributes['os.version'] = { value: osDeviceAttributes['os.version'], type: 'string' }; + attributes['os.version'] = { value: osDeviceAttributes['os.version'], type: 'string' }; } if (osDeviceAttributes['device.brand']) { - log.attributes['device.brand'] = { value: osDeviceAttributes['device.brand'], type: 'string' }; + attributes['device.brand'] = { value: osDeviceAttributes['device.brand'], type: 'string' }; } if (osDeviceAttributes['device.model']) { - log.attributes['device.model'] = { value: osDeviceAttributes['device.model'], type: 'string' }; + attributes['device.model'] = { value: osDeviceAttributes['device.model'], type: 'string' }; } if (osDeviceAttributes['device.family']) { - log.attributes['device.family'] = { value: osDeviceAttributes['device.family'], type: 'string' }; + attributes['device.family'] = { value: osDeviceAttributes['device.family'], type: 'string' }; } + return attributes; +} + +function handleLogFromRenderer( + client: Client, + options: ElectronMainOptionsInternal, + log: SerializedLog, + contents: WebContents | undefined, +): void { + log.attributes = handleAttributes(client, options, contents, log.attributes); _INTERNAL_captureSerializedLog(client, log); } +function handleMetricFromRenderer( + client: Client, + options: ElectronMainOptionsInternal, + metric: SerializedMetric, + contents: WebContents | undefined, +): void { + metric.attributes = handleAttributes(client, options, contents, metric.attributes); + _INTERNAL_captureSerializedMetric(client, metric); +} + /** Enables Electron protocol handling */ function configureProtocol(client: Client, ipcUtil: IpcUtils, options: ElectronMainOptionsInternal): void { if (app.isReady()) { @@ -247,6 +269,8 @@ function configureProtocol(client: Client, ipcUtil: IpcUtils, options: ElectronM handleEnvelope(client, options, data, getWebContents()); } else if (ipcUtil.urlMatches(request.url, 'structured-log') && data) { handleLogFromRenderer(client, options, JSON.parse(data.toString()), getWebContents()); + } else if (ipcUtil.urlMatches(request.url, 'metric') && data) { + handleMetricFromRenderer(client, options, JSON.parse(data.toString()), getWebContents()); } else if (rendererStatusChanged && ipcUtil.urlMatches(request.url, 'status') && data) { const contents = getWebContents(); if (contents) { @@ -289,6 +313,9 @@ function configureClassic(client: Client, ipcUtil: IpcUtils, options: ElectronMa ipcMain.on(ipcUtil.createKey('structured-log'), ({ sender }, log: SerializedLog) => handleLogFromRenderer(client, options, log, sender), ); + ipcMain.on(ipcUtil.createKey('metric'), ({ sender }, metric: SerializedMetric) => + handleMetricFromRenderer(client, options, metric, sender), + ); const rendererStatusChanged = createRendererEventLoopBlockStatusHandler(client); if (rendererStatusChanged) { diff --git a/src/preload/index.ts b/src/preload/index.ts index e6f4297b..cf06a87d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +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 { SerializedLog, SerializedMetric } from '@sentry/core'; import { contextBridge, ipcRenderer } from 'electron'; import { ipcChannelUtils, RendererStatus } from '../common/ipc.js'; @@ -28,6 +28,7 @@ export function hookupIpc(namespace: string = 'sentry-ipc'): void { sendEnvelope: (envelope: Uint8Array | string) => ipcRenderer.send(ipcUtil.createKey('envelope'), envelope), sendStatus: (status: RendererStatus) => ipcRenderer.send(ipcUtil.createKey('status'), status), sendStructuredLog: (log: SerializedLog) => ipcRenderer.send(ipcUtil.createKey('structured-log'), log), + sendMetric: (metric: SerializedMetric) => ipcRenderer.send(ipcUtil.createKey('metric'), metric), }; // eslint-disable-next-line no-restricted-globals diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 3f163790..778e623a 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,6 +1,7 @@ import * as logger from './log.js'; +import * as metrics from './metric.js'; -export { logger }; +export { logger, metrics }; export type { Breadcrumb, @@ -84,7 +85,6 @@ export { lastEventId, launchDarklyIntegration, linkedErrorsIntegration, - metrics, moduleMetadataIntegration, onLoad, openFeatureIntegration, diff --git a/src/renderer/ipc.ts b/src/renderer/ipc.ts index 6fb81bd5..0b2c6ed1 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 { Client, debug, getClient, SerializedLog, uuid4 } from '@sentry/core'; +import { Client, debug, getClient, SerializedLog, SerializedMetric, uuid4 } from '@sentry/core'; import { ipcChannelUtils, IPCInterface, RENDERER_ID_HEADER, RendererStatus } from '../common/ipc.js'; import { ElectronRendererOptionsInternal } from './sdk.js'; @@ -56,6 +56,15 @@ function getImplementation(ipcKey: string): IPCInterface { // ignore }); }, + sendMetric: (metric: SerializedMetric) => { + fetch(ipcUtil.createUrl('metric'), { + method: 'POST', + body: JSON.stringify(metric), + headers, + }).catch(() => { + // ignore + }); + }, }; } } diff --git a/src/renderer/metric.ts b/src/renderer/metric.ts new file mode 100644 index 00000000..a58ce427 --- /dev/null +++ b/src/renderer/metric.ts @@ -0,0 +1,122 @@ +import { + _INTERNAL_captureMetric, + getClient, + getCurrentScope, + MetricOptions, + MetricType, + SerializedMetric, +} from '@sentry/core'; +import { getIPC } from './ipc.js'; + +function captureMetric(type: MetricType, name: string, value: number, options?: MetricOptions): void { + const client = getClient(); + _INTERNAL_captureMetric( + { type, name, value, unit: options?.unit, attributes: options?.attributes }, + { + scope: options?.scope ?? getCurrentScope(), + captureSerializedMetric: (_: unknown, metric: SerializedMetric) => getIPC(client).sendMetric(metric), + }, + ); +} + +/** + * @summary Increment a counter metric. Requires the `_experiments.enableMetrics` option to be enabled. + * + * @param name - The name of the counter metric. + * @param value - The value to increment by (defaults to 1). + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.count('api.requests', 1, { + * attributes: { + * endpoint: '/api/users', + * method: 'GET', + * status: 200 + * } + * }); + * ``` + * + * @example With custom value + * + * ``` + * Sentry.metrics.count('items.processed', 5, { + * attributes: { + * processor: 'batch-processor', + * queue: 'high-priority' + * } + * }); + * ``` + */ +export function count(name: string, value: number = 1, options?: MetricOptions): void { + captureMetric('counter', name, value, options); +} + +/** + * @summary Set a gauge metric to a specific value. Requires the `_experiments.enableMetrics` option to be enabled. + * + * @param name - The name of the gauge metric. + * @param value - The current value of the gauge. + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.gauge('memory.usage', 1024, { + * unit: 'megabyte', + * attributes: { + * process: 'web-server', + * region: 'us-east-1' + * } + * }); + * ``` + * + * @example Without unit + * + * ``` + * Sentry.metrics.gauge('active.connections', 42, { + * attributes: { + * server: 'api-1', + * protocol: 'websocket' + * } + * }); + * ``` + */ +export function gauge(name: string, value: number, options?: MetricOptions): void { + captureMetric('gauge', name, value, options); +} + +/** + * @summary Record a value in a distribution metric. Requires the `_experiments.enableMetrics` option to be enabled. + * + * @param name - The name of the distribution metric. + * @param value - The value to record in the distribution. + * @param options - Options for capturing the metric. + * + * @example + * + * ``` + * Sentry.metrics.distribution('task.duration', 500, { + * unit: 'millisecond', + * attributes: { + * task: 'data-processing', + * priority: 'high' + * } + * }); + * ``` + * + * @example Without unit + * + * ``` + * Sentry.metrics.distribution('batch.size', 100, { + * attributes: { + * processor: 'batch-1', + * type: 'async' + * } + * }); + * ``` + */ +export function distribution(name: string, value: number, options?: MetricOptions): void { + captureMetric('distribution', name, value, options); +} diff --git a/test/e2e/test-apps/other/metrics/package.json b/test/e2e/test-apps/other/metrics/package.json new file mode 100644 index 00000000..d698feee --- /dev/null +++ b/test/e2e/test-apps/other/metrics/package.json @@ -0,0 +1,9 @@ +{ + "name": "electron-metrics", + "description": "Electron Metrics", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "7.2.0" + } +} diff --git a/test/e2e/test-apps/other/metrics/src/index.html b/test/e2e/test-apps/other/metrics/src/index.html new file mode 100644 index 00000000..938c2147 --- /dev/null +++ b/test/e2e/test-apps/other/metrics/src/index.html @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/other/metrics/src/main.js b/test/e2e/test-apps/other/metrics/src/main.js new file mode 100644 index 00000000..a298d60d --- /dev/null +++ b/test/e2e/test-apps/other/metrics/src/main.js @@ -0,0 +1,35 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init, metrics } = require('@sentry/electron/main'); + +init({ + dsn: '__DSN__', + debug: true, + onFatalError: () => {}, +}); + +app.on('ready', () => { + const mainWindow = new BrowserWindow({ + show: false, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); + + setTimeout(() => { + metrics.count('User profile updated', 1, { + attributes: { + userId: 'user_123', + updatedFields: ['email', 'preferences'], + }, + }); + }, 500); +}); + +setTimeout(() => { + app.quit(); +}, 4000); diff --git a/test/e2e/test-apps/other/metrics/test.ts b/test/e2e/test-apps/other/metrics/test.ts new file mode 100644 index 00000000..6ff8ef3b --- /dev/null +++ b/test/e2e/test-apps/other/metrics/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: 'trace_metric', + item_count: expect.any(Number), + content_type: 'application/vnd.sentry.items.trace-metric+json', + }, + { + items: expect.arrayContaining([ + { + timestamp: expect.any(Number), + name: 'User profile updated', + type: 'counter', + value: 1, + trace_id: UUID_MATCHER, + attributes: expect.objectContaining({ + 'sentry.release': { value: 'electron-metrics@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' }, + userId: { value: 'user_123', type: 'string' }, + updatedFields: { value: '["email","preferences"]', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + trace_id: UUID_MATCHER, + name: 'User clicked submit button', + type: 'counter', + value: 1, + attributes: expect.objectContaining({ + buttonId: { value: 'submit-form', type: 'string' }, + formId: { value: 'user-profile', type: 'string' }, + 'sentry.release': { value: 'electron-metrics@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' }, + 'electron.process': { value: 'renderer', type: 'string' }, + }), + }, + ]), + }, + ], + ], + ], + }) + .run(); +});