Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion __tests__/config/sentryConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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: {
Expand Down Expand Up @@ -84,6 +100,21 @@ const runBeforeSendWith = (event: Partial<ErrorEvent>): 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();
Expand Down Expand Up @@ -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();
});
});
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1234567890
versionName "1.20.27"
versionName "1.21.27"
}

buildTypes {
Expand Down
8 changes: 4 additions & 4 deletions ios/freighter-mobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.27;
MARKETING_VERSION = 1.21.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -542,7 +542,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.27;
MARKETING_VERSION = 1.21.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -740,7 +740,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.27;
MARKETING_VERSION = 1.21.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -775,7 +775,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.27;
MARKETING_VERSION = 1.21.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down
2 changes: 1 addition & 1 deletion ios/freighter-mobile/Info-Dev.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.20.27</string>
<string>1.21.27</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand Down
2 changes: 1 addition & 1 deletion ios/freighter-mobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.20.27</string>
<string>1.21.27</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freighter-mobile",
"version": "1.20.27",
"version": "1.21.27",
"license": "Apache-2.0",
"scripts": {
"android": "yarn android-dev",
Expand Down
14 changes: 13 additions & 1 deletion src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ const PreferencesScreen: React.FC<PreferencesScreenProps> = () => {
const preferencesItems: PreferenceListItem[] = useMemo(
() => [
{
title: t("preferences.anonymousDataSharing.title"),
title: t("preferences.usageDataSharing.title"),
titleColor: themeColors.text.primary,
description: t("preferences.anonymousDataSharing.description"),
description: t("preferences.usageDataSharing.description"),
trailingContent: renderAnalyticsToggle(),
testID: "anonymous-data-sharing-item",
},
Expand Down
82 changes: 81 additions & 1 deletion src/config/sentryConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,43 @@ 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
if (isE2ETest) {
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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
};
10 changes: 5 additions & 5 deletions src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,19 @@
"deleteAccountConfirmMessage": "Make sure you have your recovery phrase in a safe place. You will not be able to recover your wallet without it."
},
"preferences": {
"anonymousDataSharing": {
"title": "Anonymous data sharing",
"description": "Allow Freighter to collect limited information about usage. We will collect public keys, transaction amounts, and balances. This information will not be used for marketing or to identify you personally. We will not collect information such as name, address, email addresses, phone numbers, etc."
"usageDataSharing": {
"title": "Usage data sharing",
"description": "Help us improve Freighter by sharing usage, device, and activity data, including your public keys, IP address, and a persistent ID that links your wallet across extension and mobile, with our analytics and crash-reporting providers. You can turn this off at any time. See our Privacy Policy for details."
},
"permissionModal": {
"enable": {
"title": "Enable Analytics",
"description": "To enable anonymous data sharing, you'll need to allow tracking permissions in your device settings.",
"description": "To enable usage data sharing, you'll need to allow tracking permissions in your device settings.",
"instruction": "Tap \"Open Settings\" below, then find Freighter and enable \"Allow Tracking\"."
},
"disable": {
"title": "Disable Analytics",
"description": "To disable anonymous data sharing, you'll need to change tracking permissions in your device settings.",
"description": "To disable usage data sharing, you'll need to change tracking permissions in your device settings.",
"instruction": "Tap \"Open Settings\" below, then find Freighter and disable \"Allow Tracking\"."
},
"openSettings": "Open Settings",
Expand Down
Loading
Loading