From 116a5c82b52e1ce0616a8895d81b99e0a064f2a7 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 28 Jul 2026 23:38:00 -0400 Subject: [PATCH 1/4] fix(sentry): turn Sentry fully off when data sharing is off (mirror extension) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously mobile initialized Sentry unconditionally and kept sending error/crash events (with reduced context) even when the user turned data sharing off — unlike the extension, which never initializes Sentry when sharing is disabled. Make the data-sharing toggle the master switch for Sentry: (1) initializeSentry() no-ops when sharing is off (no client on a cold start); (2) beforeSend hard-drops every event while off (covers the runtime toggle-off window and any lingering/native client); (3) a new syncSentryEnablement(), called from the analytics-store subscription, (re)initializes on toggle-on and clears the user + closes the client on toggle-off. App.tsx already consent-gates the startup setUser. Caveat (documented): a JS-side close() cannot fully tear down native crash handlers mid-session, so a native crash between a runtime toggle-off and the next app launch could still be captured natively; on the next launch with sharing off, init is skipped entirely. Mirrors the extension's own 'close isn't complete until refresh' behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/sentryConfig.test.ts | 68 +++++++++++++++++++++++++++ src/config/sentryConfig.ts | 42 +++++++++++++++++ src/services/analytics/core.ts | 14 ++++++ 3 files changed, 124 insertions(+) diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index e4efa6513..7cffea488 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -5,11 +5,13 @@ import { PASSWORD_TYPO_MESSAGES, initializeSentry, scrubStrKeys, + syncSentryEnablement, updateSentryContext, } from "config/sentryConfig"; jest.mock("@sentry/react-native", () => ({ init: jest.fn(), + close: jest.fn(), setContext: jest.fn(), setTag: jest.fn(), setUser: jest.fn(), @@ -498,3 +500,69 @@ describe("sentryConfig.beforeSend filters", () => { }); }); }); + +// Mirrors the extension: the data-sharing toggle is the master switch for +// Sentry — off means the client is never initialized (cold start) and every +// event is dropped (runtime), and toggling flips the client on/off. +describe("data-sharing master switch", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAnalyticsState.isEnabled = true; + }); + + it("does NOT initialize Sentry when data sharing is off", () => { + mockAnalyticsState.isEnabled = false; + initializeSentry(); + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); + + it("initializes Sentry when data sharing is on", () => { + mockAnalyticsState.isEnabled = true; + initializeSentry(); + expect(mockedSentry.init).toHaveBeenCalledTimes(1); + }); + + it("beforeSend drops every event while data sharing is off, passes when on", () => { + mockAnalyticsState.isEnabled = true; + initializeSentry(); + const beforeSend = mockedSentry.init.mock.calls[0]?.[0]?.beforeSend; + expect(beforeSend).toBeDefined(); + + const event = { + exception: { values: [{ type: "Error", value: "a real bug" }] }, + } as unknown as ErrorEvent; + + // Enabled: a normal event passes through. + expect(beforeSend!(event, {})).not.toBeNull(); + + // Disabled: the same event is dropped. + mockAnalyticsState.isEnabled = false; + expect(beforeSend!(event, {})).toBeNull(); + }); + + it("syncSentryEnablement shuts the client down (clears user + close) on toggle-off", () => { + // Bring the client up first so the internal flag is set. + mockAnalyticsState.isEnabled = true; + initializeSentry(); + jest.clearAllMocks(); + + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + expect(mockedSentry.setUser).toHaveBeenCalledWith(null); + expect(mockedSentry.close).toHaveBeenCalled(); + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); + + it("syncSentryEnablement re-initializes the client on toggle-on", () => { + // Drive to a shut-down state (init, then close via sync). + mockAnalyticsState.isEnabled = true; + initializeSentry(); + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + jest.clearAllMocks(); + + mockAnalyticsState.isEnabled = true; + syncSentryEnablement(); + expect(mockedSentry.init).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/config/sentryConfig.ts b/src/config/sentryConfig.ts index 93b798823..1e62ba3ed 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -179,6 +179,11 @@ export const updateSentryContext = (): void => { } }; +// Tracks whether the Sentry client is currently running, so the data-sharing +// toggle can (re)initialize or shut it down idempotently (see +// syncSentryEnablement). +let isSentryInitialized = false; + /** * Initialize Sentry with privacy-conscious configuration */ @@ -188,6 +193,13 @@ export const initializeSentry = (): void => { return; } + // Master switch: with data sharing OFF, do not initialize Sentry at all — + // mirrors the extension (no init when sharing is disabled), so nothing is + // reported. syncSentryEnablement() re-initializes if the user turns it on. + if (!useAnalyticsStore.getState().isEnabled) { + return; + } + Sentry.init({ dsn: SENTRY_CONFIG.DSN, sendDefaultPii: false, @@ -208,6 +220,13 @@ export const initializeSentry = (): void => { appHangTimeoutInterval: 5, beforeSend(event) { + // Master switch (defense-in-depth): if data sharing is off, drop every + // event. Covers the window between a runtime toggle-off and client + // teardown, and any event from a lingering or native-layer client. + if (!useAnalyticsStore.getState().isEnabled) { + return null; + } + // Drop or downgrade known-noise patterns before any PII scrubbing // or context updates. Each entry should describe a noise source // we've seen in production (third-party SDK quirks, native auth @@ -328,6 +347,29 @@ export const initializeSentry = (): void => { }, }); + isSentryInitialized = true; + // Set initial context and tags updateSentryContext(); }; + +/** + * Reconcile Sentry with the current data-sharing preference. Idempotent and + * safe to call on any analytics-store change: when sharing is ON it + * (re)initializes Sentry; when sharing is OFF it clears the user and shuts the + * client down so nothing further is reported. Mirrors the extension's + * init-when-allowed / close-on-disable behavior. + */ +export const syncSentryEnablement = (): void => { + const { isEnabled } = useAnalyticsStore.getState(); + + if (isEnabled && !isSentryInitialized) { + initializeSentry(); + } else if (!isEnabled && isSentryInitialized) { + // Drop the identity, then shut the client down. beforeSend also hard-drops + // events while disabled, so nothing leaks in the gap before close resolves. + Sentry.setUser(null); + Sentry.close(); + isSentryInitialized = false; + } +}; diff --git a/src/services/analytics/core.ts b/src/services/analytics/core.ts index 0baaf179c..c0fded40b 100644 --- a/src/services/analytics/core.ts +++ b/src/services/analytics/core.ts @@ -3,6 +3,7 @@ import { Experiment } from "@amplitude/experiment-react-native-client"; import { hash } from "@stellar/stellar-sdk"; import { AnalyticsEvent, isScreenViewEvent } from "config/analyticsConfig"; import { logger } from "config/logger"; +import { syncSentryEnablement } from "config/sentryConfig"; import { useAnalyticsStore } from "ducks/analytics"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -505,6 +506,19 @@ useAnalyticsStore.subscribe((state) => { ); } + // Turn Sentry fully on/off with the same toggle (mirrors the extension): + // (re)initialize when sharing is enabled, shut the client down when disabled. + // Also covers consent that hydrates/enables AFTER the initial init ran. + try { + syncSentryEnablement(); + } catch (error) { + logger.error( + DEBUG_CONFIG.LOG_PREFIX, + "Failed to sync Sentry enablement", + error, + ); + } + // Consent may hydrate/enable AFTER init runs, so the in-init sync can // correctly skip (opted-out, nothing cached). Re-sync here so traits reach // Amplitude once consent becomes allowed. The dirty-check + consent-gate in From e1a5ceddadf9ca6523aa33b3e3c2be3c9930760f Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 29 Jul 2026 13:40:23 -0400 Subject: [PATCH 2/4] fix: address Copilot review on Sentry master-switch - Make initializeSentry() idempotent (guard on isSentryInitialized) so the App startup effect and the analytics-store subscription can't double-init regardless of ordering. - Defer App's cold-start Sentry setup until the persisted data-sharing preference has hydrated from AsyncStorage. Zustand's pre-hydration default is `true`, so an un-deferred init could briefly initialize Sentry for a returning opted-out user. Mirrors services/analytics/core.ts. - Reset module init state before each sentryConfig test so the new idempotency guard doesn't suppress init in later tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/sentryConfig.test.ts | 11 +++++++++++ src/components/App.tsx | 14 +++++++++++++- src/config/sentryConfig.ts | 8 ++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index 7cffea488..d6438c15c 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -86,6 +86,17 @@ const runBeforeSendWith = (event: Partial): ErrorEvent | null => { return initOpts.beforeSend(event as ErrorEvent, {}) as ErrorEvent | null; }; +// initializeSentry() is idempotent — it guards on an internal +// `isSentryInitialized` flag. Reset that module state before every test (drive +// it to "not initialized" via the public syncSentryEnablement path) so each +// test starts fresh and stays isolated. +beforeEach(() => { + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + mockAnalyticsState.isEnabled = true; + jest.clearAllMocks(); +}); + describe("updateSentryContext user-identity consent gate", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/components/App.tsx b/src/components/App.tsx index 66c16eab3..234c9ca58 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -88,7 +88,19 @@ export const App = (): React.JSX.Element => { } }; - initSentry(); + // Defer until the persisted data-sharing preference has hydrated from + // AsyncStorage. Zustand's pre-hydration default is `true` (Android), so + // running initSentry() before hydration could initialize Sentry for a + // returning opted-out user in the brief window before the stored `false` + // restores — breaking the cold-start opt-out. Mirrors the analytics + // module's onFinishHydration handling in services/analytics/core.ts. + if (useAnalyticsStore.persist.hasHydrated()) { + initSentry(); + return undefined; + } + return useAnalyticsStore.persist.onFinishHydration(() => { + initSentry(); + }); }, []); return ( diff --git a/src/config/sentryConfig.ts b/src/config/sentryConfig.ts index 1e62ba3ed..0d451e16b 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -193,6 +193,14 @@ export const initializeSentry = (): void => { return; } + // Idempotent: never run Sentry.init() twice. Both App's startup effect and + // the analytics-store subscription (via syncSentryEnablement) can reach here, + // and their order isn't guaranteed — guard the initializer itself so whichever + // runs second is a no-op. + if (isSentryInitialized) { + return; + } + // Master switch: with data sharing OFF, do not initialize Sentry at all — // mirrors the extension (no init when sharing is disabled), so nothing is // reported. syncSentryEnablement() re-initializes if the user turns it on. From 04b157d98006a268d9230129fc522708f3b97009 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 29 Jul 2026 13:44:53 -0400 Subject: [PATCH 3/4] fix: disable Sentry on opt-out without draining the transport backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's remaining (low-confidence) review point: Sentry.close() always does a full-drain flush before disabling, so toggling data sharing off could push out events buffered under prior consent (e.g. from an offline window) — contradicting "off means off". Go through the client directly with a 0ms flush timeout: still sets enabled=false (no future capture/send), but does not actively drain the backlog. beforeSend continues to hard-drop anything captured in the gap before close resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/sentryConfig.test.ts | 7 ++++++- src/config/sentryConfig.ts | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index d6438c15c..f73f213aa 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -9,9 +9,11 @@ import { updateSentryContext, } from "config/sentryConfig"; +const mockClientClose = jest.fn(); jest.mock("@sentry/react-native", () => ({ init: jest.fn(), close: jest.fn(), + getClient: jest.fn(() => ({ close: mockClientClose })), setContext: jest.fn(), setTag: jest.fn(), setUser: jest.fn(), @@ -560,7 +562,10 @@ describe("data-sharing master switch", () => { mockAnalyticsState.isEnabled = false; syncSentryEnablement(); expect(mockedSentry.setUser).toHaveBeenCalledWith(null); - expect(mockedSentry.close).toHaveBeenCalled(); + // Disable via the client with a 0ms flush timeout — stop sending without + // draining the transport backlog (Sentry.close() would full-flush). + expect(mockClientClose).toHaveBeenCalledWith(0); + expect(mockedSentry.close).not.toHaveBeenCalled(); expect(mockedSentry.init).not.toHaveBeenCalled(); }); diff --git a/src/config/sentryConfig.ts b/src/config/sentryConfig.ts index 0d451e16b..e2cf0435f 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -374,10 +374,22 @@ export const syncSentryEnablement = (): void => { if (isEnabled && !isSentryInitialized) { initializeSentry(); } else if (!isEnabled && isSentryInitialized) { - // Drop the identity, then shut the client down. beforeSend also hard-drops - // events while disabled, so nothing leaks in the gap before close resolves. + // Drop the identity, then disable the client. We call close() with a 0ms + // flush timeout (Sentry.close() itself always does a full-drain flush, so + // go through the client directly): on opt-out we want to *stop* sending, + // not actively drain the transport backlog — a full flush would push out + // any events buffered under prior consent (e.g. from an offline window), + // which contradicts "off means off". close() still sets enabled=false, so + // no future event is captured or sent, and beforeSend hard-drops anything + // captured in the gap before it resolves. In-flight requests already handed + // to the network can't be recalled, but nothing new is drained. Sentry.setUser(null); - Sentry.close(); + const client = Sentry.getClient(); + if (client) { + client.close(0); + } else { + Sentry.close(); + } isSentryInitialized = false; } }; From 530f75df9f22e2d621997c7a15da267bb5a31178 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 29 Jul 2026 14:17:09 -0400 Subject: [PATCH 4/4] fix: correct Sentry opt-out disable + guard subscription against pre-hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the cloud review's blocking finding plus two 75-scored near-misses: - BLOCKING: client.close(0) did NOT skip the transport drain. In @sentry/core@10.18.0, PromiseBuffer.drain treats a falsy timeout (0 or undefined) as "wait until the whole queue drains", and RN's Sentry.close() passes no timeout at all — so opt-out still full-flushed events buffered under prior consent, the exact behavior the code claimed to avoid. Disable by flipping getOptions().enabled = false directly (what close() does after its flush): captureEvent's _isEnabled() guard blocks all future sends, no drain. - syncSentryEnablement() read isEnabled with no hasHydrated() guard. Called from the analytics-store subscription, which can fire pre-hydration (setUserId during identify) when the Android default is `true` — initializing Sentry for a returning opted-out user. Guard on persist.hasHydrated(), mirroring syncIdentifyTraits; App's startup effect owns the initial post-hydration reconcile. - Updated initializeSentry() JSDoc to document its three no-op cases (e2e / already-initialized / sharing-off), per anti-patterns.md. Tests: assert enabled flips to false (not close()); add a pre-hydration no-op case. 49/49 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/config/sentryConfig.test.ts | 45 ++++++++++++++++++++---- src/config/sentryConfig.ts | 50 ++++++++++++++++++--------- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index f73f213aa..9ca06e13b 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -9,11 +9,17 @@ import { updateSentryContext, } from "config/sentryConfig"; +// Shared client options object so tests can assert that opt-out flips +// `enabled` to false (the non-flushing disable) rather than calling close(). +const mockClientOptions: { enabled: boolean } = { enabled: true }; const mockClientClose = jest.fn(); jest.mock("@sentry/react-native", () => ({ init: jest.fn(), close: jest.fn(), - getClient: jest.fn(() => ({ close: mockClientClose })), + getClient: jest.fn(() => ({ + close: mockClientClose, + getOptions: () => mockClientOptions, + })), setContext: jest.fn(), setTag: jest.fn(), setUser: jest.fn(), @@ -38,8 +44,14 @@ const mockAnalyticsState: { isEnabled: boolean; userId: string | null } = { isEnabled: true, userId: null, }; +// Persisted-consent hydration flag. Default true so direct-call tests exercise +// the post-hydration path; a dedicated test flips it false to cover the race. +const mockHydration = { hydrated: true }; jest.mock("ducks/analytics", () => ({ - useAnalyticsStore: { getState: () => mockAnalyticsState }, + useAnalyticsStore: { + getState: () => mockAnalyticsState, + persist: { hasHydrated: () => mockHydration.hydrated }, + }, })); jest.mock("ducks/auth", () => ({ useAuthenticationStore: { @@ -93,9 +105,13 @@ const runBeforeSendWith = (event: Partial): ErrorEvent | null => { // it to "not initialized" via the public syncSentryEnablement path) so each // test starts fresh and stays isolated. beforeEach(() => { + mockHydration.hydrated = true; mockAnalyticsState.isEnabled = false; syncSentryEnablement(); mockAnalyticsState.isEnabled = true; + // Reset AFTER the syncSentryEnablement reset above, which flips enabled off + // when a prior test left the client initialized. + mockClientOptions.enabled = true; jest.clearAllMocks(); }); @@ -553,7 +569,7 @@ describe("data-sharing master switch", () => { expect(beforeSend!(event, {})).toBeNull(); }); - it("syncSentryEnablement shuts the client down (clears user + close) on toggle-off", () => { + it("syncSentryEnablement disables the client on toggle-off without flushing", () => { // Bring the client up first so the internal flag is set. mockAnalyticsState.isEnabled = true; initializeSentry(); @@ -562,15 +578,18 @@ describe("data-sharing master switch", () => { mockAnalyticsState.isEnabled = false; syncSentryEnablement(); expect(mockedSentry.setUser).toHaveBeenCalledWith(null); - // Disable via the client with a 0ms flush timeout — stop sending without - // draining the transport backlog (Sentry.close() would full-flush). - expect(mockClientClose).toHaveBeenCalledWith(0); + // Disable by flipping enabled=false directly — NOT via close()/close(0), + // both of which full-drain the transport (PromiseBuffer.drain treats a + // falsy timeout as "wait for the whole queue"). We must not push out the + // backlog buffered under prior consent. + expect(mockClientOptions.enabled).toBe(false); + expect(mockClientClose).not.toHaveBeenCalled(); expect(mockedSentry.close).not.toHaveBeenCalled(); expect(mockedSentry.init).not.toHaveBeenCalled(); }); it("syncSentryEnablement re-initializes the client on toggle-on", () => { - // Drive to a shut-down state (init, then close via sync). + // Drive to a shut-down state (init, then disable via sync). mockAnalyticsState.isEnabled = true; initializeSentry(); mockAnalyticsState.isEnabled = false; @@ -581,4 +600,16 @@ describe("data-sharing master switch", () => { syncSentryEnablement(); expect(mockedSentry.init).toHaveBeenCalledTimes(1); }); + + it("syncSentryEnablement is a no-op before persisted consent hydrates", () => { + // Returning opted-out user on Android: store default is `true`, but the + // persisted preference (still un-hydrated) is `false`. The subscription + // must not initialize Sentry off the pre-hydration default. + mockHydration.hydrated = false; + mockAnalyticsState.isEnabled = true; + + syncSentryEnablement(); + + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); }); diff --git a/src/config/sentryConfig.ts b/src/config/sentryConfig.ts index e2cf0435f..903e6eb6c 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -185,7 +185,15 @@ export const updateSentryContext = (): void => { let isSentryInitialized = false; /** - * Initialize Sentry with privacy-conscious configuration + * Initialize Sentry with privacy-conscious configuration. + * + * No-ops (does not call Sentry.init) in three cases: + * - during e2e tests; + * - if Sentry is already initialized (idempotent — safe to call from both + * App's startup effect and the analytics-store subscription regardless of + * order); + * - if data sharing is currently OFF (master switch; mirrors the extension). + * syncSentryEnablement() re-invokes this when the user turns sharing back on. */ export const initializeSentry = (): void => { // Disable Sentry during e2e tests @@ -364,31 +372,41 @@ export const initializeSentry = (): void => { /** * Reconcile Sentry with the current data-sharing preference. Idempotent and * safe to call on any analytics-store change: when sharing is ON it - * (re)initializes Sentry; when sharing is OFF it clears the user and shuts the - * client down so nothing further is reported. Mirrors the extension's - * init-when-allowed / close-on-disable behavior. + * (re)initializes Sentry; when sharing is OFF it clears the user and disables + * the client so nothing further is reported. Mirrors the extension's + * init-when-allowed / disable-on-opt-out behavior. */ export const syncSentryEnablement = (): void => { + // Consent (isEnabled) is persisted to AsyncStorage and hydrates + // asynchronously; before hydration the store holds its default, which is + // `true` on Android (ANALYTICS_CONFIG.DEFAULT_ENABLED). This runs from the + // analytics-store subscription, which can fire pre-hydration (e.g. setUserId + // during identify), so reading isEnabled now could initialize Sentry for a + // returning opted-out user. Treat persisted consent as authoritative and + // skip until hydration completes — App's startup effect performs the initial + // reconcile from onFinishHydration. Mirrors syncIdentifyTraits in + // services/analytics/core.ts. + if (!useAnalyticsStore.persist.hasHydrated()) return; + const { isEnabled } = useAnalyticsStore.getState(); if (isEnabled && !isSentryInitialized) { initializeSentry(); } else if (!isEnabled && isSentryInitialized) { - // Drop the identity, then disable the client. We call close() with a 0ms - // flush timeout (Sentry.close() itself always does a full-drain flush, so - // go through the client directly): on opt-out we want to *stop* sending, - // not actively drain the transport backlog — a full flush would push out - // any events buffered under prior consent (e.g. from an offline window), - // which contradicts "off means off". close() still sets enabled=false, so - // no future event is captured or sent, and beforeSend hard-drops anything - // captured in the gap before it resolves. In-flight requests already handed - // to the network can't be recalled, but nothing new is drained. + // Drop the identity, then disable the client WITHOUT flushing. Neither + // Sentry.close() nor client.close(0) work here: close() always runs + // flush(timeout) first, and PromiseBuffer.drain treats a falsy timeout + // (0 or undefined) as "wait until the whole queue drains" — so both would + // push out events buffered under prior consent (e.g. from an offline + // window), contradicting "off means off". Flip `enabled = false` directly + // (what close() does after its flush): captureEvent's `_isEnabled()` guard + // then blocks every future send, and beforeSend hard-drops anything caught + // in the gap. In-flight network requests already handed off can't be + // recalled, but nothing new is drained. Sentry.setUser(null); const client = Sentry.getClient(); if (client) { - client.close(0); - } else { - Sentry.close(); + client.getOptions().enabled = false; } isSentryInitialized = false; }