diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index e4efa6513..9ca06e13b 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -5,11 +5,21 @@ import { PASSWORD_TYPO_MESSAGES, initializeSentry, scrubStrKeys, + syncSentryEnablement, 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, + getOptions: () => mockClientOptions, + })), setContext: jest.fn(), setTag: jest.fn(), setUser: jest.fn(), @@ -34,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: { @@ -84,6 +100,21 @@ 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(() => { + 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(); +}); + describe("updateSentryContext user-identity consent gate", () => { beforeEach(() => { jest.clearAllMocks(); @@ -498,3 +529,87 @@ 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 disables the client on toggle-off without flushing", () => { + // 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); + // 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 disable via sync). + mockAnalyticsState.isEnabled = true; + initializeSentry(); + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + jest.clearAllMocks(); + + mockAnalyticsState.isEnabled = true; + 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/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 93b798823..903e6eb6c 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -179,8 +179,21 @@ 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 + * 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 @@ -188,6 +201,21 @@ 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. + if (!useAnalyticsStore.getState().isEnabled) { + return; + } + Sentry.init({ dsn: SENTRY_CONFIG.DSN, sendDefaultPii: false, @@ -208,6 +236,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 +363,51 @@ 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 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 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.getOptions().enabled = false; + } + 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