Skip to content

Commit

Permalink
feat(core): add feature flag switch for experience app (#6564)
Browse files Browse the repository at this point in the history
* 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
simeng-li and gao-sun authored Sep 20, 2024
1 parent 5ddb64d commit 918f850
Show file tree
Hide file tree
Showing 7 changed files with 408 additions and 16 deletions.
37 changes: 24 additions & 13 deletions packages/core/src/middleware/koa-spa-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from 'node:fs/promises';
import path from 'node:path';

import type { MiddlewareType } from 'koa';
import { type Nullable, trySafe } from '@silverhand/essentials';
import type { Context, MiddlewareType } from 'koa';
import proxy from 'koa-proxies';
import type { IRouterParamContext } from 'koa-router';

Expand All @@ -11,6 +12,7 @@ import type Queries from '#src/tenants/Queries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';

import serveCustomUiAssets from './koa-serve-custom-ui-assets.js';
import { getExperiencePackageWithFeatureFlagDetection } from './utils/experience-proxy.js';

type Properties = {
readonly mountedApps: string[];
Expand All @@ -20,15 +22,20 @@ type Properties = {
readonly prefix?: string;
};

const getDistributionPath = (packagePath: string) => {
const getDistributionPath = async <ContextT extends Context>(
packagePath: string,
ctx: ContextT
): Promise<[string, string]> => {
if (packagePath === 'experience') {
// Use the new experience package if dev features are enabled
const moduleName = EnvSet.values.isDevFeaturesEnabled ? 'experience' : 'experience-legacy';
// Safely get the experience package name with feature flag detection, default fallback to legacy
const moduleName =
(await trySafe(async () => getExperiencePackageWithFeatureFlagDetection(ctx))) ??
'experience-legacy';

return path.join('node_modules/@logto', moduleName, 'dist');
return [path.join('node_modules/@logto', moduleName, 'dist'), moduleName];
}

return path.join('node_modules/@logto', packagePath, 'dist');
return [path.join('node_modules/@logto', packagePath, 'dist'), packagePath];
};

export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>({
Expand All @@ -40,10 +47,9 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
}: Properties): MiddlewareType<StateT, ContextT, ResponseBodyT> {
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;

const distributionPath = getDistributionPath(packagePath);

const spaProxy: Middleware = EnvSet.values.isProduction
? serveStatic(distributionPath)
// Avoid defining a devProxy if we are in production
const devProxy: Nullable<Middleware> = EnvSet.values.isProduction
? null
: proxy('*', {
target: `http://localhost:${port}`,
changeOrigin: true,
Expand Down Expand Up @@ -76,16 +82,21 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
return serve(ctx, next);
}

if (!EnvSet.values.isProduction) {
return spaProxy(ctx, next);
// Use the devProxy under development mode
if (devProxy) {
return devProxy(ctx, next);
}

const [distributionPath, moduleName] = await getDistributionPath(packagePath, ctx);
const spaDistributionFiles = await fs.readdir(distributionPath);

if (!spaDistributionFiles.some((file) => requestPath.startsWith('/' + file))) {
ctx.request.path = '/';
}

return spaProxy(ctx, next);
// Add a header to indicate which static package is being served
ctx.set('Logto-Static-Package', moduleName);

return serveStatic(distributionPath)(ctx, next);
};
}
193 changes: 193 additions & 0 deletions packages/core/src/middleware/utils/experience-proxy.test.ts
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();
});
});
86 changes: 86 additions & 0 deletions packages/core/src/middleware/utils/experience-proxy.ts
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';
};
Loading

0 comments on commit 918f850

Please sign in to comment.