-
-
Notifications
You must be signed in to change notification settings - Fork 423
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add feature flag switch for experience app (#6564)
* feat(core,schemas): implement experience package a/b test implement experience package a/b test * refactor(core,schemas): rename to featureFlag rename to featureFlag * refactor(core): replace the hash alg replace the hash alg and add trySafe wrapper * chore(core): update function name and comments update function name and comments * refactor(core): optimize the code logic optimize the code logic, add head to indicate the experience package * refactor(core): update static module proxy header update static module proxy header * fix(core): fix unit test fix unit test * fix(core): clean up empty line clean up empty line Co-authored-by: Gao Sun <gao@silverhand.io> --------- Co-authored-by: Gao Sun <gao@silverhand.io>
- Loading branch information
Showing
7 changed files
with
408 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
193 changes: 193 additions & 0 deletions
193
packages/core/src/middleware/utils/experience-proxy.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
import { TtlCache } from '@logto/shared'; | ||
import { createMockUtils } from '@logto/shared/esm'; | ||
import Sinon from 'sinon'; | ||
|
||
import { EnvSet } from '#src/env-set/index.js'; | ||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; | ||
|
||
const { jest } = import.meta; | ||
|
||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest); | ||
|
||
const mockFindSystemByKey = jest.fn(); | ||
const mockIsRequestInTestGroup = jest.fn().mockReturnValue(true); | ||
const mockTtlCache = new TtlCache(60 * 60 * 1000); // 1 hour | ||
|
||
mockEsm('#src/queries/system.js', () => ({ | ||
createSystemsQuery: jest.fn(() => ({ | ||
findSystemByKey: mockFindSystemByKey, | ||
})), | ||
})); | ||
|
||
mockEsm('#src/utils/feature-flag.js', () => ({ | ||
isFeatureEnabledForEntity: mockIsRequestInTestGroup, | ||
})); | ||
|
||
await mockEsmWithActual('@logto/shared', () => ({ | ||
TtlCache: jest.fn().mockImplementation(() => mockTtlCache), | ||
})); | ||
|
||
const { getExperiencePackageWithFeatureFlagDetection } = await import('./experience-proxy.js'); | ||
|
||
describe('experience proxy with feature flag detection test', () => { | ||
const envBackup = process.env; | ||
const _interaction = '12345678'; | ||
|
||
beforeEach(() => { | ||
process.env = { ...envBackup }; | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
jest.resetModules(); | ||
mockTtlCache.clear(); | ||
}); | ||
|
||
const mockContext = createMockContext({ | ||
url: '/sign-in', | ||
cookies: { | ||
_interaction, | ||
}, | ||
}); | ||
|
||
it('should return the new experience package if dev features are enabled', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: true, | ||
}); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
|
||
expect(result).toBe('experience'); | ||
expect(mockFindSystemByKey).not.toBeCalled(); | ||
expect(mockIsRequestInTestGroup).not.toBeCalled(); | ||
|
||
stub.restore(); | ||
}); | ||
|
||
it('should return the legacy experience package if not in the cloud', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: false, | ||
}); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
|
||
expect(result).toBe('experience-legacy'); | ||
expect(mockFindSystemByKey).not.toBeCalled(); | ||
expect(mockIsRequestInTestGroup).not.toBeCalled(); | ||
|
||
stub.restore(); | ||
}); | ||
|
||
it('should return the legacy experience package if the session ID is not found', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: true, | ||
}); | ||
|
||
const mockContextWithEmptyCookie = createMockContext({ | ||
url: '/sign-in', | ||
cookies: { | ||
foo: 'bar', | ||
}, | ||
}); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContextWithEmptyCookie); | ||
expect(result).toBe('experience-legacy'); | ||
expect(mockFindSystemByKey).not.toBeCalled(); | ||
expect(mockIsRequestInTestGroup).not.toBeCalled(); | ||
|
||
stub.restore(); | ||
}); | ||
|
||
it('should return 0% if no settings is found in the systems db', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: true, | ||
}); | ||
|
||
mockFindSystemByKey.mockResolvedValueOnce(null); | ||
mockIsRequestInTestGroup.mockReturnValueOnce(false); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
expect(result).toBe('experience-legacy'); | ||
expect(mockFindSystemByKey).toBeCalled(); | ||
expect(mockIsRequestInTestGroup).toBeCalledWith({ | ||
entityId: _interaction, | ||
rollOutPercentage: 0, | ||
}); | ||
|
||
stub.restore(); | ||
}); | ||
|
||
it.each([{ foo: 'bar' }, { percentage: 90 }, { percentage: 1.5 }])( | ||
'should return 0% if the system settings is invalid: %p', | ||
async (percentage) => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: true, | ||
}); | ||
|
||
mockFindSystemByKey.mockResolvedValueOnce({ value: percentage }); | ||
mockIsRequestInTestGroup.mockReturnValueOnce(false); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
expect(result).toBe('experience-legacy'); | ||
expect(mockFindSystemByKey).toBeCalled(); | ||
expect(mockIsRequestInTestGroup).toBeCalledWith({ | ||
entityId: _interaction, | ||
rollOutPercentage: 0, | ||
}); | ||
|
||
stub.restore(); | ||
} | ||
); | ||
|
||
it('should get the package path based on the feature flag settings in the systems db', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: true, | ||
}); | ||
|
||
mockFindSystemByKey.mockResolvedValueOnce({ value: { percentage: 0.5 } }); | ||
mockIsRequestInTestGroup.mockReturnValueOnce(true); | ||
|
||
const result = await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
expect(result).toBe('experience'); | ||
expect(mockFindSystemByKey).toBeCalled(); | ||
expect(mockIsRequestInTestGroup).toBeCalledWith({ | ||
entityId: _interaction, | ||
rollOutPercentage: 0.5, | ||
}); | ||
|
||
stub.restore(); | ||
}); | ||
|
||
it('should get the package path based on the cached feature flag settings', async () => { | ||
const stub = Sinon.stub(EnvSet, 'values').value({ | ||
...EnvSet.values, | ||
isDevFeaturesEnabled: false, | ||
isCloud: true, | ||
}); | ||
|
||
mockFindSystemByKey.mockResolvedValueOnce({ value: { percentage: 0.5 } }); | ||
|
||
await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
await getExperiencePackageWithFeatureFlagDetection(mockContext); | ||
|
||
expect(mockFindSystemByKey).toBeCalledTimes(1); | ||
expect(mockIsRequestInTestGroup).toBeCalledTimes(2); | ||
expect(mockIsRequestInTestGroup).toBeCalledWith({ | ||
entityId: _interaction, | ||
rollOutPercentage: 0.5, | ||
}); | ||
|
||
stub.restore(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
/** | ||
* This file provides the utility functions for the experience package proxy with feature flag detection. | ||
* Should clean up this file once feature flag is fully rolled out. | ||
*/ | ||
|
||
import { featureFlagConfigGuard, FeatureFlagConfigKey } from '@logto/schemas'; | ||
import { TtlCache } from '@logto/shared'; | ||
import type { Context } from 'koa'; | ||
|
||
import { EnvSet } from '#src/env-set/index.js'; | ||
import { createSystemsQuery } from '#src/queries/system.js'; | ||
import { isFeatureEnabledForEntity } from '#src/utils/feature-flag.js'; | ||
|
||
const interactionCookieName = '_interaction'; | ||
|
||
const featureFlagSettingsCache = new TtlCache<string, number>(60 * 60 * 1000); // 1 hour | ||
|
||
/** | ||
* Get the feature flag rollout percentage from the system settings. | ||
* | ||
* - return the cached percentage if it exists. | ||
* - read the percentage from the system settings if no cache exists. | ||
* - return 0% if the system settings are not found. | ||
*/ | ||
const getFeatureFlagSettings = async () => { | ||
const cachedPercentage = featureFlagSettingsCache.get( | ||
FeatureFlagConfigKey.NewExperienceFeatureFlag | ||
); | ||
|
||
if (cachedPercentage !== undefined) { | ||
return cachedPercentage; | ||
} | ||
|
||
const sharedAdminPool = await EnvSet.sharedPool; | ||
const { findSystemByKey } = createSystemsQuery(sharedAdminPool); | ||
const flagConfig = await findSystemByKey(FeatureFlagConfigKey.NewExperienceFeatureFlag); | ||
|
||
const result = featureFlagConfigGuard.safeParse(flagConfig?.value); | ||
|
||
if (result.success) { | ||
const { percentage } = result.data; | ||
featureFlagSettingsCache.set(FeatureFlagConfigKey.NewExperienceFeatureFlag, percentage); | ||
return percentage; | ||
} | ||
|
||
// Default to 0% if the system settings are not found | ||
featureFlagSettingsCache.set(FeatureFlagConfigKey.NewExperienceFeatureFlag, 0); | ||
return 0; | ||
}; | ||
|
||
/** | ||
* We will roll out the new experience based on the session ID. | ||
* | ||
* - Always return the new experience package if dev features are enabled. | ||
* - Always return the legacy experience package for OSS. Until the new experience is fully rolled out. | ||
* - Roll out the new experience package based on the session ID for cloud. | ||
* - The feature flag enabled percentage is read from DB system settings. | ||
*/ | ||
export const getExperiencePackageWithFeatureFlagDetection = async <ContextT extends Context>( | ||
ctx: ContextT | ||
) => { | ||
if (EnvSet.values.isDevFeaturesEnabled) { | ||
return 'experience'; | ||
} | ||
|
||
// Always use the legacy experience package if not in the cloud, until the new experience is fully rolled out | ||
if (!EnvSet.values.isCloud) { | ||
return 'experience-legacy'; | ||
} | ||
|
||
const interactionSessionId = ctx.cookies.get(interactionCookieName); | ||
|
||
// No session ID found, fall back to the legacy experience | ||
if (!interactionSessionId) { | ||
return 'experience-legacy'; | ||
} | ||
|
||
const rollOutPercentage = await getFeatureFlagSettings(); | ||
|
||
const isEligibleForNewExperience = isFeatureEnabledForEntity({ | ||
entityId: interactionSessionId, | ||
rollOutPercentage, | ||
}); | ||
|
||
return isEligibleForNewExperience ? 'experience' : 'experience-legacy'; | ||
}; |
Oops, something went wrong.