Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support running without a STATSIG_API_KEY #6403

Merged
merged 5 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
45 changes: 33 additions & 12 deletions src/analytics/AppAnalytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createClient } from '@segment/analytics-react-native'
import { PincodeType } from 'src/account/reducer'
import AppAnalyticsModule from 'src/analytics/AppAnalytics'
import { OnboardingEvents } from 'src/analytics/Events'
import * as config from 'src/config'
import { store } from 'src/redux/store'
import { getDefaultStatsigUser, getFeatureGate, getMultichainFeatures } from 'src/statsig'
import { NetworkId } from 'src/transactions/types'
Expand All @@ -25,10 +26,6 @@ jest.mock('@segment/analytics-react-native-plugin-clevertap')
jest.mock('@segment/analytics-react-native-plugin-firebase')
jest.mock('@sentry/react-native', () => ({ init: jest.fn() }))
jest.mock('src/redux/store', () => ({ store: { getState: jest.fn() } }))
jest.mock('src/config', () => ({
...(jest.requireActual('src/config') as any),
STATSIG_API_KEY: 'statsig-key',
}))
jest.mock('statsig-react-native')
jest.mock('src/statsig')
jest.mock('src/web3/networkConfig', () => {
Expand Down Expand Up @@ -186,6 +183,7 @@ beforeAll(() => {

describe('AppAnalytics', () => {
let AppAnalytics: typeof AppAnalyticsModule
let mockConfig: jest.MockedObject<typeof config>
const mockSegmentClient = {
identify: jest.fn().mockResolvedValue(undefined),
track: jest.fn().mockResolvedValue(undefined),
Expand All @@ -205,22 +203,45 @@ describe('AppAnalytics', () => {
jest.unmock('src/analytics/AppAnalytics')
jest.isolateModules(() => {
AppAnalytics = require('src/analytics/AppAnalytics').default
mockConfig = require('src/config')
})

mockConfig.STATSIG_API_KEY = 'statsig-key'
mockConfig.STATSIG_ENABLED = true
mockStore.getState.mockImplementation(() => state)
jest.mocked(getFeatureGate).mockReturnValue(true)
jest.mocked(getMultichainFeatures).mockReturnValue({
showBalances: [NetworkId['celo-alfajores']],
})
})

it('creates statsig client on initialization with default statsig user', async () => {
jest.mocked(getDefaultStatsigUser).mockReturnValue({ userID: 'someUserId' })
await AppAnalytics.init()
expect(Statsig.initialize).toHaveBeenCalledWith(
'statsig-key',
{ userID: 'someUserId' },
{ environment: { tier: 'development' }, overrideStableID: 'anonId', localMode: false }
)
describe('init', () => {
it('initializes segment', async () => {
await AppAnalytics.init()
expect(mockCreateSegmentClient).toHaveBeenCalled()
})

it('does not initialize segment if SEGMENT_API_KEY is not present', async () => {
mockConfig.SEGMENT_API_KEY = undefined
await AppAnalytics.init()
expect(mockCreateSegmentClient).not.toHaveBeenCalled()
})

it('creates statsig client on initialization with default statsig user', async () => {
jest.mocked(getDefaultStatsigUser).mockReturnValue({ userID: 'someUserId' })
await AppAnalytics.init()
expect(Statsig.initialize).toHaveBeenCalledWith(
'statsig-key',
{ userID: 'someUserId' },
{ environment: { tier: 'development' }, overrideStableID: 'anonId', localMode: false }
)
})

it('does not initialize statsig if STATSIG_ENABLED is false', async () => {
mockConfig.STATSIG_ENABLED = false
await AppAnalytics.init()
expect(Statsig.initialize).not.toHaveBeenCalled()
})
})

it('delays identify calls until async init has finished', async () => {
Expand Down
93 changes: 47 additions & 46 deletions src/analytics/AppAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
import { getCurrentUserTraits } from 'src/analytics/selectors'
import {
DEFAULT_TESTNET,
E2E_TEST_STATSIG_ID,
FIREBASE_ENABLED,
isE2EEnv,
SEGMENT_API_KEY,
STATSIG_API_KEY,
STATSIG_ENABLED,
STATSIG_ENV,
} from 'src/config'
import { store } from 'src/redux/store'
Expand Down Expand Up @@ -101,60 +101,61 @@

async init() {
let uniqueID
try {
if (!SEGMENT_API_KEY) {
throw Error('API Key not present, likely due to environment. Skipping enabling')
}
this.segmentClient = createClient({
debug: __DEV__,
trackAppLifecycleEvents: true,
trackDeepLinks: true,
writeKey: SEGMENT_API_KEY,
storePersistor: AsyncStoragePersistor,
})
if (SEGMENT_API_KEY) {
try {
this.segmentClient = createClient({
debug: __DEV__,
trackAppLifecycleEvents: true,
trackDeepLinks: true,
writeKey: SEGMENT_API_KEY,
storePersistor: AsyncStoragePersistor,
})

this.segmentClient.add({ plugin: new DestinationFiltersPlugin() })
this.segmentClient.add({ plugin: new InjectTraits() })
this.segmentClient.add({ plugin: new AdjustPlugin() })
this.segmentClient.add({ plugin: new ClevertapPlugin() })
if (FIREBASE_ENABLED) {
this.segmentClient.add({ plugin: new FirebasePlugin() })
}
this.segmentClient.add({ plugin: new DestinationFiltersPlugin() })
this.segmentClient.add({ plugin: new InjectTraits() })
this.segmentClient.add({ plugin: new AdjustPlugin() })
this.segmentClient.add({ plugin: new ClevertapPlugin() })
if (FIREBASE_ENABLED) {
this.segmentClient.add({ plugin: new FirebasePlugin() })
}

try {
const deviceInfo = await getDeviceInfo()
this.deviceInfo = deviceInfo
uniqueID = deviceInfo.UniqueID
this.sessionId = sha256(Buffer.from(uniqueID + String(Date.now()))).slice(2)
} catch (error) {
Logger.error(TAG, 'getDeviceInfo error', error)
}
try {
const deviceInfo = await getDeviceInfo()
this.deviceInfo = deviceInfo
uniqueID = deviceInfo.UniqueID
this.sessionId = sha256(Buffer.from(uniqueID + String(Date.now()))).slice(2)
} catch (error) {
Logger.error(TAG, 'getDeviceInfo error', error)

Check warning on line 128 in src/analytics/AppAnalytics.ts

View check run for this annotation

Codecov / codecov/patch

src/analytics/AppAnalytics.ts#L128

Added line #L128 was not covered by tests
}

Logger.info(TAG, 'Segment Analytics Integration initialized!')
} catch (err) {
const error = ensureError(err)
Logger.error(TAG, `Segment setup error: ${error.message}\n`, error)
Logger.info(TAG, 'Segment Analytics Integration initialized!')
} catch (err) {
const error = ensureError(err)
Logger.error(TAG, `Segment setup error: ${error.message}\n`, error)

Check warning on line 134 in src/analytics/AppAnalytics.ts

View check run for this annotation

Codecov / codecov/patch

src/analytics/AppAnalytics.ts#L133-L134

Added lines #L133 - L134 were not covered by tests
}
} else {
Logger.info(TAG, 'Segment API key not present, skipping setup')
}

try {
const statsigUser = getDefaultStatsigUser()
// getAnonymousId causes the e2e tests to fail
let overrideStableID: string = E2E_TEST_STATSIG_ID
if (!isE2EEnv) {
if (STATSIG_ENABLED) {
try {
const statsigUser = getDefaultStatsigUser()
if (!this.segmentClient) {
throw new Error('segmentClient is undefined, cannot get anonymous ID')
}
overrideStableID = this.segmentClient.userInfo.get().anonymousId
const overrideStableID = this.segmentClient.userInfo.get().anonymousId
Logger.debug(TAG, 'Statsig stable ID', overrideStableID)
await Statsig.initialize(STATSIG_API_KEY, statsigUser, {
// StableID should match Segment anonymousId
overrideStableID,
environment: STATSIG_ENV,
localMode: isE2EEnv,
Copy link
Contributor

@satish-ravi satish-ravi Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just initialize the statsig client in local mode if statsig is disabled? That way we can avoid the custom gate overrides code too. And we can avoid the noisy logs by tweaking the logging condition.

Also, it seems like for statsig to be enabled, we'll need the segment key too because of the stable id dependency. Wonder if we should remove that dependency. We can probably let statsig set it's own anonymous id in cases where segment is not enabled.

Copy link
Member Author

@jeanregisser jeanregisser Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I didn't know about the localMode option.
However my intention is to be able to run even if the statsig lib is not installed. So the approach here of not instantiating the client is preferrable I think. We're planning to make 3rd party deps like segment, clevertap, etc optional in the modular runtime.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it seems like for statsig to be enabled, we'll need the segment key too because of the stable id dependency. Wonder if we should remove that dependency. We can probably let statsig set it's own anonymous id in cases where segment is not enabled.

Yes I thought about that, and was on the fence whether to make that change now.
I'm not sure if we'll need statsig enabled without segment.
To run experiments we need segment to be connected to statsig, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense re running without installing those libraries.
For experiments, yes segment is needed but it doesn't need to be. Metrics can potentially go to statsig directly without segment. Also, maybe there's a case where statsig is just needed for dynamic config. But I guess we could do that separately.

should we just drop the localMode option now that it is always going to be false?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes definitely I'll remove it.

})
} catch (error) {
Logger.warn(TAG, `Statsig setup error`, error)
}
Logger.debug(TAG, 'Statsig stable ID', overrideStableID)
await Statsig.initialize(STATSIG_API_KEY, statsigUser, {
// StableID should match Segment anonymousId
overrideStableID,
environment: STATSIG_ENV,
localMode: isE2EEnv,
})
} catch (error) {
Logger.warn(TAG, `Statsig setup error`, error)
} else {
Logger.info(TAG, 'Statsig is not enabled, skipping setup')
}
}

Expand Down
7 changes: 2 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export const FIATCONNECT_NETWORK =
export const STATSIG_ENV = {
tier: DEFAULT_TESTNET === 'mainnet' ? 'production' : 'development',
}
export const E2E_TEST_STATSIG_ID = 'e2e_test_statsig_id'

// Keyless backup settings
export const TORUS_NETWORK =
Expand Down Expand Up @@ -122,10 +121,8 @@ export const ALCHEMY_BASE_API_KEY = keyOrUndefined(
)

export const ZENDESK_API_KEY = keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'ZENDESK_API_KEY')
export const STATSIG_API_KEY =
keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'STATSIG_API_KEY') ??
// dummy key as fallback for e2e tests, which use local mode
'client-key'
export const STATSIG_API_KEY = keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'STATSIG_API_KEY')
export const STATSIG_ENABLED = !isE2EEnv && !!STATSIG_API_KEY
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept isE2EEnv here as when running locally, the secrets are decrypted, but we should still keep statsig disabled.

I think with the move to the runtime we'll update "secrets" management and will just use env variables. So this special case of e2e env will not be needed anymore.

export const SEGMENT_API_KEY = keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'SEGMENT_API_KEY')
export const SENTRY_CLIENT_URL = keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'SENTRY_CLIENT_URL')
export const BIDALI_URL = keyOrUndefined(secretsFile, DEFAULT_TESTNET, 'BIDALI_URL')
Expand Down
5 changes: 5 additions & 0 deletions src/navigator/SettingsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ jest.mock('statsig-react-native', () => ({
},
}))

jest.mock('src/config', () => ({
...jest.requireActual('src/config'),
STATSIG_ENABLED: true,
}))

describe('SettingsMenu', () => {
beforeEach(() => {
jest.clearAllMocks()
Expand Down
3 changes: 2 additions & 1 deletion src/navigator/SettingsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import ContactCircleSelf from 'src/components/ContactCircleSelf'
import GradientBlock from 'src/components/GradientBlock'
import { SettingsItemTextValue } from 'src/components/SettingsItem'
import Touchable from 'src/components/Touchable'
import { STATSIG_ENABLED } from 'src/config'
import Envelope from 'src/icons/Envelope'
import ForwardChevron from 'src/icons/ForwardChevron'
import Lock from 'src/icons/Lock'
Expand Down Expand Up @@ -162,7 +163,7 @@ export default function SettingsMenu({ route }: Props) {
if (!devModeActive) {
return null
} else {
const statsigStableId = Statsig.getStableID()
const statsigStableId = STATSIG_ENABLED ? Statsig.getStableID() : 'statsig-not-enabled'
return (
<View style={styles.devSettings}>
<Touchable onPress={onCopyText(sessionId)} style={styles.devSettingsItem}>
Expand Down
61 changes: 54 additions & 7 deletions src/statsig/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { LaunchArguments } from 'react-native-launch-arguments'
import * as config from 'src/config'
import { store } from 'src/redux/store'
import { DynamicConfigs, ExperimentConfigs } from 'src/statsig/constants'
import {
_getGateOverrides,
getDynamicConfigParams,
getExperimentParams,
getFeatureGate,
Expand All @@ -25,6 +27,8 @@ jest.mock('src/redux/store', () => ({ store: { getState: jest.fn() } }))
jest.mock('statsig-react-native')
jest.mock('src/utils/Logger')

const mockConfig = jest.mocked(config)

const mockStore = jest.mocked(store)
const MOCK_ACCOUNT = '0x000000000000000000000000000000000000000000'
const MOCK_START_ONBOARDING_TIME = 1680563877
Expand All @@ -38,6 +42,10 @@ mockStore.getState.mockImplementation(() =>
describe('Statsig helpers', () => {
beforeEach(() => {
jest.clearAllMocks()
mockConfig.STATSIG_ENABLED = true
// Reset gate overrides before each test
jest.mocked(LaunchArguments.value).mockReturnValue({ statsigGateOverrides: 'dummy=true' })
setupOverridesFromLaunchArgs()
})
describe('data validation', () => {
it.each(Object.entries(ExperimentConfigs))(
Expand Down Expand Up @@ -110,6 +118,14 @@ describe('Statsig helpers', () => {
expect(getMock).toHaveBeenCalledWith('param2', 'defaultValue2')
expect(output).toEqual({ param1: 'statsigValue1', param2: 'statsigValue2' })
})
it('returns default values if statsig is not enabled', () => {
mockConfig.STATSIG_ENABLED = false
const defaultValues = { param1: 'defaultValue1', param2: 'defaultValue2' }
const experimentName = 'mock_experiment_name' as StatsigExperiments
const output = getExperimentParams({ experimentName, defaultValues })
expect(output).toEqual(defaultValues)
expect(Logger.warn).not.toHaveBeenCalled()
})
})

describe('getFeatureGate', () => {
Expand All @@ -127,6 +143,20 @@ describe('Statsig helpers', () => {
expect(Logger.warn).not.toHaveBeenCalled()
expect(output).toEqual(true)
})
it('returns gate overrides if set', () => {
jest.mocked(Statsig.checkGate).mockImplementation(() => true)
jest
.mocked(LaunchArguments.value)
.mockReturnValue({ statsigGateOverrides: 'app_review=false' })
setupOverridesFromLaunchArgs()
expect(getFeatureGate(StatsigFeatureGates.APP_REVIEW)).toEqual(false)
})
it('returns default values if statsig is not enabled', () => {
mockConfig.STATSIG_ENABLED = false
const output = getFeatureGate(StatsigFeatureGates.APP_REVIEW)
expect(output).toEqual(false)
expect(Logger.warn).not.toHaveBeenCalled()
})
})

describe('getMultichainFeatures', () => {
Expand Down Expand Up @@ -208,6 +238,14 @@ describe('Statsig helpers', () => {
StatsigMultiNetworkDynamicConfig.MULTI_CHAIN_FEATURES
)
})
it('returns default values if statsig is not enabled', () => {
mockConfig.STATSIG_ENABLED = false
const output = getMultichainFeatures()
expect(output).toEqual(
DynamicConfigs[StatsigMultiNetworkDynamicConfig.MULTI_CHAIN_FEATURES].defaultValues
)
expect(Logger.warn).not.toHaveBeenCalled()
})
})

describe('getDynamicConfigParams', () => {
Expand Down Expand Up @@ -267,6 +305,14 @@ describe('Statsig helpers', () => {
expect(getMock).toHaveBeenCalledWith('param2', 'defaultValue2')
expect(output).toEqual({ param1: 'statsigValue1', param2: 'statsigValue2' })
})
it('returns default values if statsig is not enabled', () => {
mockConfig.STATSIG_ENABLED = false
const defaultValues = { param1: 'defaultValue1', param2: 'defaultValue2' }
const configName = 'mock_config' as StatsigDynamicConfigs
const output = getDynamicConfigParams({ configName, defaultValues })
expect(output).toEqual(defaultValues)
expect(Logger.warn).not.toHaveBeenCalled()
})
})
describe('patchUpdateStatsigUser', () => {
let mockDateNow: jest.SpyInstance
Expand Down Expand Up @@ -349,25 +395,26 @@ describe('Statsig helpers', () => {
},
})
})
it('does not update user if statsig is not enabled', async () => {
mockConfig.STATSIG_ENABLED = false
await patchUpdateStatsigUser()
expect(Statsig.updateUser).not.toHaveBeenCalled()
})
})

describe('setupOverridesFromLaunchArgs', () => {
it('cleans up overrides and skips setup if no override is set', () => {
jest.mocked(LaunchArguments.value).mockReturnValue({})
jest.mocked(LaunchArguments.value).mockReturnValue({ statsigGateOverrides: '' })
setupOverridesFromLaunchArgs()
expect(Statsig.removeGateOverride).toHaveBeenCalledWith()
expect(Statsig.overrideGate).not.toHaveBeenCalled()
expect(_getGateOverrides()).toEqual({})
})

it('cleans up and sets up gate overrides if set', () => {
jest
.mocked(LaunchArguments.value)
.mockReturnValue({ statsigGateOverrides: 'gate1=true,gate2=false' })
setupOverridesFromLaunchArgs()
expect(Statsig.removeGateOverride).toHaveBeenCalledWith()
expect(Statsig.overrideGate).toHaveBeenCalledTimes(2)
expect(Statsig.overrideGate).toHaveBeenCalledWith('gate1', true)
expect(Statsig.overrideGate).toHaveBeenCalledWith('gate2', false)
expect(_getGateOverrides()).toEqual({ gate1: true, gate2: false })
})
})
})
Loading
Loading