Repository: getsentry/sentry-electron
Pull request #890 (2024-04-23 13:09:25)

docs: Migration & Readme
Updates the Readme and adds a v4 to v5 section to the Migration docs. 


Patch:
diff --git a/MIGRATION.md b/MIGRATION.md
index e54025e..482c65d 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,7 +1,130 @@
+# Upgrading from 4.x to 5.x
+
+Many breaking changes in v5 are due to changes in the underlying Sentry JavaScript SDKs so be sure to check the
+[JavaScript v8 migration
+docs](https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md#upgrading-from-7x-to-8x).
+
+## Supported Electron Versions
+
+The Sentry Node SDK now requires Node >= 14.18.0 which means the Sentry Electron SDK now supports Electron >= 15.0.0.
+
+## Initializing the SDK in v5
+
+From v5, the root export (`@sentry/electron`) is no longer used and will throw and error if it's imported.
+
+In previous versions of the SDK, this export would attempt to detect the Electron process type and import the correct
+entry point. This was problematic for many bundlers, caused issues with tree shaking and obfuscated the fact that the
+there are distinct SDKs for each Electron process.
+
+In the Electron main process you should import from `@sentry/electron/main`:
+```javascript
+import * as Sentry from '@sentry/electron/main';
+
+Sentry.init({
+  dsn: '__DSN__',
+  // ...more options
+});
+```
+
+In all Electron renderer processes you should import from `@sentry/electron/renderer`:
+```javascript
+// In the Electron renderer processes
+import * as Sentry from '@sentry/electron/renderer';
+
+// It's not a requirement to pass any options in the renderer processes because
+// much of the functionality is configured from the main process
+Sentry.init();
+```
+
+## Deprecated Class Based Integrations
+
+In v5, integrations are no longer classes and instead the are functions. Both the use as a class, as well as accessing
+integrations from the Integrations.XXX hash, is deprecated in favor of using the new functional integrations.
+
+
+| Old | New |
+| --- | --- |
+| `new Integrations.SentryMinidump()` | `sentryMinidumpIntegration()` |
+| `new Integrations.AdditionalContext()` | `additionalContextIntegration()` |
+| `new Integrations.Anr()` | `anrIntegration()` |
+| `new Integrations.BrowserWindowSession()` | `browserWindowSessionIntegration()` |
+| `new Integrations.ChildProcess()` | `childProcessIntegration()` |
+| `new Integrations.ElectronBreadcrumbs()` | `electronBreadcrumbsIntegration()` |
+| `new Integrations.ElectronMinidump()` | `electronMinidumpIntegration()` |
+| `new Integrations.MainProcessSession()` | `mainProcessSessionIntegration()` |
+| `new Integrations.Net()` | `electronNetIntegration()` |
+| `new Integrations.OnUncaughtException()` | `onUncaughtExceptionIntegration()` |
+| `new Integrations.PreloadInjection()` | `preloadInjectionIntegration()` |
+| `new Integrations.RendererProfiling()` | `rendererProfilingIntegration()` |
+| `new Integrations.Screenshots()` | `screenshotsIntegration()` |
+
+## New Default Integrations
+
+### `electronContextIntegration()`
+
+Adds Electron specific context information to events on top of that already supplied by the Node SDK.
+
+### `normalizePathsIntegration()`
+
+Normalizes paths in stack traces and URLs to be relative to the app root.
+
+## `event_type` tag removed
+
+The `event_type` tag was deprecated in favor of the `event.environment` tag in v4 and has been removed entirely in v5.
+
+## Offline Transport Options
+
+The offline transport now uses the logic from `@sentry/core` which more closely follows the transport specification.
+This means the `beforeSend` callback has been replaced by the `shouldSend` and `shouldStore` callbacks.
+
+Previously the `beforeSend` callback signature was:
+```typescript
+type BeforeSendResponse = 'send' | 'queue' | 'drop';
+
+/**
+ * Called before attempting to send an event to Sentry.
+ *
+ * Return 'send' to attempt to send the event.
+ * Return 'queue' to queue and persist the event for sending later.
+ * Return 'drop' to drop the event.
+ */
+beforeSend?: (request: TransportRequest) => BeforeSendResponse | Promise<BeforeSendResponse>;
+```
+
+The new `shouldSend` and `shouldStore` callbacks have the following signatures:
+```typescript
+/**
+ * Called before we attempt to send an envelope to Sentry.
+ *
+ * If this function returns false, `shouldStore` will be called to determine if the envelope should be stored.
+ *
+ * Default: () => true
+ *
+ * @param envelope The envelope that will be sent.
+ * @returns Whether we should attempt to send the envelope
+ */
+shouldSend?: (envelope: Envelope) => boolean | Promise<boolean>;
+
+/**
+ * Called before an event is stored.
+ *
+ * Return false to drop the envelope rather than store it.
+ *
+ * Default: () => true
+ *
+ * @param envelope The envelope that failed to send.
+ * @param error The error that occurred.
+ * @param retryDelay The current retry delay in milliseconds.
+ * @returns Whether we should store the envelope in the queue
+ */
+shouldStore?: (envelope: Envelope, error: Error, retryDelay: number) => boolean | Promise<boolean>;
+```
+
+
 # Upgrading from 3.x to 4.x
 
 All the breaking changes in v4 are due to changes in the underlying Sentry JavaScript SDKs so be sure to check the
-[JavaScript migration docs](https://github.com/getsentry/sentry-javascript/blob/master/MIGRATION.md#upgrading-from-6x-to-7x).
+[JavaScript v7 migration docs](https://github.com/getsentry/sentry-javascript/blob/master/MIGRATION.md#upgrading-from-6x-to-7x).
 
 # Upgrading from 2.x to 3.x
 
diff --git a/README.md b/README.md
index 0ac5be5..63572bf 100644
--- a/README.md
+++ b/README.md
@@ -21,29 +21,29 @@ Test](https://github.com/getsentry/sentry-electron/actions/workflows/build.yml/b
   [`@sentry/browser`](https://github.com/getsentry/sentry-javascript/tree/master/packages/browser))
 - Captures **native crashes** (Minidump crash reports) from renderers and the main process
 - Collects **breadcrumbs and context** information along with events across renderers and the main process
-- Supports `electron >= 2`
+- Supports `electron >= v15`
 
 ## Usage
 
 To use this SDK, call `init(options)` as early as possible in the entry modules in the main process as well as all
-renderer processes or further sub processes you spawn. This will initialize the SDK and hook into the environment.
+renderer processes or further sub processes you spawn. This will initialize the SDK and hook the environment.
 
+In the Electron main process:
 ```javascript
-import { init } from '@sentry/electron';
+import { init } from '@sentry/electron/main';
 
 init({
   dsn: '__DSN__',
   // ...
 });
 ```
-If you are using a JavaScript bundler, you may need to use the process specific imports to ensure the correct code is loaded for
-each process:
-```javascript
-// In the Electron main process
-import { init } from '@sentry/electron/main';
 
+In all Electron renderer processes:
+```javascript
 // In the Electron renderer processes
 import { init } from '@sentry/electron/renderer';
+
+init();
 ```
 
 If you are using a framework specific Sentry SDK, you can pass that `init` function as the second parameter in the
@@ -60,15 +60,13 @@ To set context information or send manual events, use the exported functions of
 functions will not perform any action before you have called `init()`:
 
 ```javascript
-import * as Sentry from '@sentry/electron';
+import * as Sentry from '@sentry/electron/main';
 
 // Set user information, as well as tags and further extras
-Sentry.configureScope((scope) => {
-  scope.setExtra('battery', 0.7);
-  scope.setTag('user_mode', 'admin');
-  scope.setUser({ id: '4711' });
-  // scope.clear();
-});
+const scope = Sentry.getCurrentScope();
+scope.setExtra('battery', 0.7);
+scope.setTag('user_mode', 'admin');
+scope.setUser({ id: '4711' });
 
 // Add a breadcrumb for future events
 Sentry.addBreadcrumb({
diff --git a/src/main/sdk.ts b/src/main/sdk.ts
index 08690b3..e146fce 100644
--- a/src/main/sdk.ts
+++ b/src/main/sdk.ts
@@ -145,7 +145,7 @@ export function init(userOptions: ElectronMainOptions): void {
     release: getDefaultReleaseName(),
     environment: getDefaultEnvironment(),
     defaultIntegrations: getDefaultIntegrations(userOptions),
-    transport: makeElectronOfflineTransport,
+    transport: makeElectronOfflineTransport(),
     transportOptions: {},
     getSessions: () => [session.defaultSession],
     ...userOptions,
diff --git a/src/main/transports/electron-offline-net.ts b/src/main/transports/electron-offline-net.ts
index e812d2d..7746551 100644
--- a/src/main/transports/electron-offline-net.ts
+++ b/src/main/transports/electron-offline-net.ts
@@ -1,23 +1,64 @@
 import { makeOfflineTransport, OfflineTransportOptions } from '@sentry/core';
-import { BaseTransportOptions, Transport } from '@sentry/types';
+import { BaseTransportOptions, Envelope, Transport } from '@sentry/types';
 
 import { ElectronNetTransportOptions, makeElectronTransport } from './electron-net';
 import { createOfflineStore, OfflineStoreOptions } from './offline-store';
 
 export type ElectronOfflineTransportOptions = ElectronNetTransportOptions &
   OfflineTransportOptions &
-  Partial<OfflineStoreOptions>;
+  Partial<OfflineStoreOptions> & {
+    /**
+     * Should we attempt to send the envelope to Sentry.
+     * If this function returns false, `shouldStore` will be called to determine if the envelope should be stored.
+     *
+     * Default: () => true
+     *
+     * @param envelope The envelope that will be sent.
+     * @returns Whether we should attempt to send the envelope
+     */
+    shouldSend?: (envelope: Envelope) => boolean | Promise<boolean>;
+  };
+
+// Transport that throws if the `shouldSend` callback returns false
+function makeShouldSendTransport<T extends BaseTransportOptions>(
+  baseTransport: (opt: T & ElectronOfflineTransportOptions) => Transport,
+): (options: T & ElectronOfflineTransportOptions) => Transport {
+  return (options: T & ElectronOfflineTransportOptions) => {
+    const transport = baseTransport(options);
+
+    return {
+      ...transport,
+      send: async (envelope) => {
+        const shouldAttemptSend = options.shouldSend === undefined || (await options.shouldSend(envelope));
+
+        if (shouldAttemptSend) {
+          return transport.send(envelope);
+        }
+
+        throw new Error("'shouldSend' callback returned false. Skipped sending.");
+      },
+    };
+  };
+}
 
 /**
  * Creates a Transport that uses Electrons net module to send events to Sentry. When they fail to send they are
  * persisted to disk and sent later
  */
-export const makeElectronOfflineTransport = <T extends BaseTransportOptions>(
-  options: T & ElectronOfflineTransportOptions,
-): Transport => {
-  return makeOfflineTransport(makeElectronTransport)({
-    flushAtStartup: true,
-    ...options,
-    createStore: createOfflineStore,
-  });
-};
+export function makeElectronOfflineTransport<T extends BaseTransportOptions>(
+  baseTransport: (opt: T & ElectronOfflineTransportOptions) => Transport = makeElectronTransport,
+): (options: T & ElectronOfflineTransportOptions) => Transport {
+  return (userOptions: T & ElectronOfflineTransportOptions): Transport => {
+    // `makeElectronOfflineTransport` is a combination of three transports.
+    //
+    // The base Electron transport (`makeElectronTransport`) is first wrapped by `makeShouldSendTransport` which skips
+    // sending events and throws when the `shouldSend` callback returns false.
+    //
+    // This is then wrapped again by `makeOfflineTransport` which stores events to disk when they fail to send.
+    return makeOfflineTransport(makeShouldSendTransport(baseTransport))({
+      flushAtStartup: true,
+      createStore: createOfflineStore,
+      ...userOptions,
+    });
+  };
+}


Test patch:
diff --git a/test/unit/offline-transport.test.ts b/test/unit/offline-transport.test.ts
new file mode 100644
index 0000000..c3991e6
--- /dev/null
+++ b/test/unit/offline-transport.test.ts
@@ -0,0 +1,146 @@
+import '../../scripts/electron-shim.mjs';
+
+import { createEventEnvelope, createTransport, OfflineTransportOptions } from '@sentry/core';
+import { Envelope, InternalBaseTransportOptions, TransportMakeRequestResponse } from '@sentry/types';
+import { describe, expect, test } from 'vitest';
+
+import { ElectronOfflineTransportOptions } from '../../src/main/transports/electron-offline-net';
+
+const { makeElectronOfflineTransport } = await import('../../src/main/transports/electron-offline-net');
+
+function EVENT_ENVELOPE(message: string = 'test', send_at?: Date) {
+  const env = createEventEnvelope({ message });
+  if (send_at) {
+    env[0].sent_at = send_at.toISOString();
+  }
+  return env;
+}
+
+type MockResult<T> = T | Error;
+
+const transportOptions = {
+  recordDroppedEvent: () => undefined, // noop
+  url: 'http://localhost:8000',
+};
+
+const createTestTransport = (...sendResults: MockResult<TransportMakeRequestResponse>[]) => {
+  const sentEnvelopes: (string | Uint8Array)[] = [];
+
+  return {
+    getSentEnvelopes: () => sentEnvelopes,
+    getSendCount: () => sentEnvelopes.length,
+    baseTransport: (options: InternalBaseTransportOptions) =>
+      createTransport(options, ({ body }) => {
+        return new Promise((resolve, reject) => {
+          const next = sendResults.shift();
+
+          if (next instanceof Error) {
+            reject(next);
+          } else {
+            sentEnvelopes.push(body);
+            resolve(next as TransportMakeRequestResponse);
+          }
+        });
+      }),
+  };
+};
+
+type StoreEvents = ('push' | 'unshift' | 'shift')[];
+
+interface OfflineStore {
+  push(env: Envelope): Promise<void>;
+  unshift(env: Envelope): Promise<void>;
+  shift(): Promise<Envelope | undefined>;
+}
+
+type CreateOfflineStore = (options: OfflineTransportOptions) => OfflineStore;
+
+function createTestStore(...popResults: MockResult<Envelope | undefined>[]): {
+  getCalls: () => StoreEvents;
+  store: CreateOfflineStore;
+} {
+  const calls: StoreEvents = [];
+
+  return {
+    getCalls: () => calls,
+    store: (_: OfflineTransportOptions) => ({
+      push: async (env) => {
+        if (popResults.length < 30) {
+          popResults.push(env);
+          calls.push('push');
+        }
+      },
+      unshift: async (env) => {
+        if (popResults.length < 30) {
+          popResults.unshift(env);
+          calls.push('unshift');
+        }
+      },
+      shift: async () => {
+        calls.push('shift');
+        const next = popResults.shift();
+
+        if (next instanceof Error) {
+          throw next;
+        }
+
+        return next;
+      },
+      count: async () => popResults.length,
+    }),
+  };
+}
+
+describe('makeElectronOfflineTransport', () => {
+  test('shouldSend allows sending', async () => {
+    expect.assertions(3);
+
+    const { baseTransport, getSendCount } = createTestTransport({ statusCode: 200 });
+    const { store: createStore, getCalls } = createTestStore();
+
+    let shouldSendCalled = 0;
+
+    const options: ElectronOfflineTransportOptions = {
+      ...transportOptions,
+      createStore,
+      shouldSend: () => {
+        shouldSendCalled++;
+        return true;
+      },
+    };
+
+    const transport = makeElectronOfflineTransport(baseTransport)(options);
+
+    await transport.send(EVENT_ENVELOPE('msg'));
+
+    expect(shouldSendCalled).toBe(1);
+    expect(getSendCount()).toBe(1);
+    expect(getCalls()).toEqual([]);
+  });
+
+  test('shouldSend stops sending and queues', async () => {
+    expect.assertions(3);
+
+    const { baseTransport, getSendCount } = createTestTransport({ statusCode: 200 });
+    const { store: createStore, getCalls } = createTestStore();
+
+    let shouldSendCalled = 0;
+
+    const options: ElectronOfflineTransportOptions = {
+      ...transportOptions,
+      createStore,
+      shouldSend: () => {
+        shouldSendCalled++;
+        return false;
+      },
+    };
+
+    const transport = makeElectronOfflineTransport(baseTransport)(options);
+
+    await transport.send(EVENT_ENVELOPE('msg'));
+
+    expect(shouldSendCalled).toBe(1);
+    expect(getSendCount()).toBe(0);
+    expect(getCalls()).toEqual(['push']);
+  });
+});