Repository: getsentry/sentry-electron
Pull request #889 (2024-04-23 10:08:22)

feat: Add `shouldSend` callback to offline transport
After migrating over to the offline support supplied by `@sentry/core` we lost the ability to force queue events which was previously requested (#489).

This PR adds a `shouldSend` callback which in combination with the `shouldStore` callback restores the previous features.


Patch:
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']);
+  });
+});