1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: Compare old results with new frontend api (#6476)

This commit is contained in:
Mateusz Kwasniewski 2024-03-08 13:03:41 +01:00 committed by GitHub
parent 1949d0134f
commit 8f105f9d30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 224 additions and 28 deletions

View File

@ -115,6 +115,7 @@ exports[`should create default config 1`] = `
},
},
"filterInvalidClientMetrics": false,
"globalFrontendApiCache": false,
"googleAuthEnabled": false,
"inMemoryScheduledChangeRequests": false,
"increaseUnleashWidth": false,

View File

@ -4,7 +4,7 @@ import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-mode
export default class FakeClientFeatureToggleReadModel
implements IClientFeatureToggleReadModel
{
constructor(private value: Record<string, IFeatureToggleClient[]>) {}
constructor(private value: Record<string, IFeatureToggleClient[]> = {}) {}
getClient(): Promise<Record<string, IFeatureToggleClient[]>> {
return Promise.resolve(this.value);

View File

@ -23,20 +23,20 @@ export class FrontendApiRepository
private readonly token: IApiUser;
private globalFrontendApiRepository: GlobalFrontendApiCache;
private globalFrontendApiCache: GlobalFrontendApiCache;
private running: boolean;
constructor(
config: Config,
globalFrontendApiRepository: GlobalFrontendApiCache,
globalFrontendApiCache: GlobalFrontendApiCache,
token: IApiUser,
) {
super();
this.config = config;
this.logger = config.getLogger('frontend-api-repository.ts');
this.token = token;
this.globalFrontendApiRepository = globalFrontendApiRepository;
this.globalFrontendApiCache = globalFrontendApiCache;
}
getTogglesWithSegmentData(): EnhancedFeatureInterface[] {
@ -45,18 +45,18 @@ export class FrontendApiRepository
}
getSegment(id: number): Segment | undefined {
return this.globalFrontendApiRepository.getSegment(id);
return this.globalFrontendApiCache.getSegment(id);
}
getToggle(name: string): FeatureInterface {
//@ts-ignore (we must update the node SDK to allow undefined)
return this.globalFrontendApiRepository
.getToggles(this.token)
.find((feature) => feature.name);
return this.getToggles(this.token).find(
(feature) => feature.name === name,
);
}
getToggles(): FeatureInterface[] {
return this.globalFrontendApiRepository.getToggles(this.token);
return this.globalFrontendApiCache.getToggles(this.token);
}
async start(): Promise<void> {

View File

@ -6,7 +6,12 @@ import noLogger from '../../test/fixtures/no-logger';
import { FakeSegmentReadModel } from '../features/segment/fake-segment-read-model';
import FakeClientFeatureToggleReadModel from './fake-client-feature-toggle-read-model';
import EventEmitter from 'events';
import { IApiUser, IFeatureToggleClient, ISegment } from '../types';
import {
IApiUser,
IFeatureToggleClient,
IFlagResolver,
ISegment,
} from '../types';
import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revision-service';
const state = async (
@ -33,11 +38,17 @@ const defaultFeature: IFeatureToggleClient = {
};
const defaultSegment = { name: 'segment', id: 1 } as ISegment;
const alwaysOnFlagResolver = {
isEnabled() {
return true;
},
} as unknown as IFlagResolver;
const createCache = (
segment: ISegment = defaultSegment,
features: Record<string, IFeatureToggleClient[]> = {},
) => {
const config = { getLogger: noLogger };
const config = { getLogger: noLogger, flagResolver: alwaysOnFlagResolver };
const segmentReadModel = new FakeSegmentReadModel([segment as ISegment]);
const clientFeatureToggleReadModel = new FakeClientFeatureToggleReadModel(
features,

View File

@ -13,7 +13,7 @@ import { UPDATE_REVISION } from '../features/feature-toggle/configuration-revisi
import { mapValues } from '../util';
import { IClientFeatureToggleReadModel } from './client-feature-toggle-read-model-type';
type Config = Pick<IUnleashConfig, 'getLogger'>;
type Config = Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>;
export type GlobalFrontendApiCacheState = 'starting' | 'ready' | 'updated';
@ -103,7 +103,9 @@ export class GlobalFrontendApiCache extends EventEmitter {
}
private async onUpdateRevisionEvent() {
await this.refreshData();
if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) {
await this.refreshData();
}
}
private environmentNameForToken(token: IApiUser): string {

View File

@ -10,6 +10,7 @@ import {
emptyResponse,
getStandardResponses,
ProxyClientSchema,
ProxyFeatureSchema,
proxyFeaturesSchema,
ProxyFeaturesSchema,
} from '../../openapi';
@ -20,6 +21,7 @@ import NotImplementedError from '../../error/not-implemented-error';
import NotFoundError from '../../error/notfound-error';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';
import isEqual from 'lodash.isequal';
interface ApiUserRequest<
PARAM = any,
@ -173,10 +175,32 @@ export default class FrontendAPIController extends Controller {
if (!this.config.flagResolver.isEnabled('embedProxy')) {
throw new NotFoundError();
}
const toggles = await this.services.proxyService.getProxyFeatures(
req.user,
FrontendAPIController.createContext(req),
);
let toggles: ProxyFeatureSchema[];
let newToggles: ProxyFeatureSchema[];
if (this.config.flagResolver.isEnabled('globalFrontendApiCache')) {
[toggles, newToggles] = await Promise.all([
this.services.proxyService.getProxyFeatures(
req.user,
FrontendAPIController.createContext(req),
),
this.services.proxyService.getNewProxyFeatures(
req.user,
FrontendAPIController.createContext(req),
),
]);
if (!isEqual(toggles, newToggles)) {
this.logger.warn(
'old features and new feature are different',
toggles.length,
newToggles.length,
);
}
} else {
toggles = await this.services.proxyService.getProxyFeatures(
req.user,
FrontendAPIController.createContext(req),
);
}
res.set('Cache-control', 'no-cache');

View File

@ -111,6 +111,9 @@ import {
import { InactiveUsersService } from '../users/inactive/inactive-users-service';
import { SegmentReadModel } from '../features/segment/segment-read-model';
import { FakeSegmentReadModel } from '../features/segment/fake-segment-read-model';
import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache';
import ClientFeatureToggleReadModel from '../proxy/client-feature-toggle-read-model';
import FakeClientFeatureToggleReadModel from '../proxy/fake-client-feature-toggle-read-model';
export const createServices = (
stores: IUnleashStores,
@ -293,12 +296,27 @@ export const createServices = (
? createClientFeatureToggleService(db, config)
: createFakeClientFeatureToggleService(config);
const proxyService = new ProxyService(config, stores, {
featureToggleServiceV2,
clientMetricsServiceV2,
settingService,
const clientFeatureToggleReadModel = db
? new ClientFeatureToggleReadModel(db, config.eventBus)
: new FakeClientFeatureToggleReadModel();
const globalFrontendApiCache = new GlobalFrontendApiCache(
config,
segmentReadModel,
clientFeatureToggleReadModel,
configurationRevisionService,
});
);
const proxyService = new ProxyService(
config,
stores,
{
featureToggleServiceV2,
clientMetricsServiceV2,
settingService,
configurationRevisionService,
},
globalFrontendApiCache,
);
const edgeService = new EdgeService({ apiTokenService }, config);

View File

@ -0,0 +1,54 @@
import { ProxyService, Config } from './proxy-service';
import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache';
import { IApiUser } from '../types';
import { FeatureInterface } from 'unleash-client/lib/feature';
import noLogger from '../../test/fixtures/no-logger';
import { ApiTokenType } from '../types/models/api-token';
test('proxy service fetching features from global cache', async () => {
const irrelevant = {} as any;
const globalFrontendApiCache = {
getToggles(_: IApiUser): FeatureInterface[] {
return [
{
name: 'toggleA',
enabled: true,
project: 'projectA',
type: 'release',
variants: [],
strategies: [
{ name: 'default', parameters: [], constraints: [] },
],
},
{
name: 'toggleB',
enabled: false,
project: 'projectA',
type: 'release',
variants: [],
strategies: [
{ name: 'default', parameters: [], constraints: [] },
],
},
];
},
} as GlobalFrontendApiCache;
const proxyService = new ProxyService(
{ getLogger: noLogger } as unknown as Config,
irrelevant,
irrelevant,
globalFrontendApiCache,
);
const features = await proxyService.getNewProxyFeatures(
{
projects: ['irrelevant'],
environment: 'irrelevant',
type: ApiTokenType.FRONTEND,
} as unknown as IApiUser,
{},
);
expect(features).toMatchObject([{ name: 'toggleA' }]);
expect(features).toHaveLength(1);
});

View File

@ -17,18 +17,20 @@ import { validateOrigins } from '../util';
import { BadDataError, InvalidTokenError } from '../error';
import { PROXY_REPOSITORY_CREATED } from '../metric-events';
import { ProxyRepository } from '../proxy';
import { FrontendApiRepository } from '../proxy/frontend-api-repository';
import { GlobalFrontendApiCache } from '../proxy/global-frontend-api-cache';
type Config = Pick<
export type Config = Pick<
IUnleashConfig,
'getLogger' | 'frontendApi' | 'frontendApiOrigins' | 'eventBus'
>;
type Stores = Pick<
export type Stores = Pick<
IUnleashStores,
'projectStore' | 'eventStore' | 'segmentReadModel'
>;
type Services = Pick<
export type Services = Pick<
IUnleashServices,
| 'featureToggleServiceV2'
| 'clientMetricsServiceV2'
@ -45,21 +47,31 @@ export class ProxyService {
private readonly services: Services;
private readonly globalFrontendApiCache: GlobalFrontendApiCache;
/**
* This is intentionally a Promise becasue we want to be able to await
* This is intentionally a Promise because we want to be able to await
* until the client (which might be being created by a different request) is ready
* Check this test that fails if we don't use a Promise: src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts
*/
private readonly clients: Map<ApiUser['secret'], Promise<Unleash>> =
new Map();
private readonly newClients: Map<ApiUser['secret'], Promise<Unleash>> =
new Map();
private cachedFrontendSettings?: FrontendSettings;
constructor(config: Config, stores: Stores, services: Services) {
constructor(
config: Config,
stores: Stores,
services: Services,
globalFrontendApiCache: GlobalFrontendApiCache,
) {
this.config = config;
this.logger = config.getLogger('services/proxy-service.ts');
this.stores = stores;
this.services = services;
this.globalFrontendApiCache = globalFrontendApiCache;
}
async getProxyFeatures(
@ -85,6 +97,33 @@ export class ProxyService {
}));
}
async getNewProxyFeatures(
token: IApiUser,
context: Context,
): Promise<ProxyFeatureSchema[]> {
const client = await this.newClientForProxyToken(token);
const definitions = client.getFeatureToggleDefinitions() || [];
const sessionId = context.sessionId || String(Math.random());
return definitions
.filter((feature) => {
const enabled = client.isEnabled(feature.name, {
...context,
sessionId,
});
return enabled;
})
.map((feature) => ({
name: feature.name,
enabled: Boolean(feature.enabled),
variant: client.getVariant(feature.name, {
...context,
sessionId,
}),
impressionData: Boolean(feature.impressionData),
}));
}
async registerProxyMetrics(
token: IApiUser,
metrics: ClientMetricsSchema,
@ -117,6 +156,20 @@ export class ProxyService {
return client;
}
private async newClientForProxyToken(token: IApiUser): Promise<Unleash> {
ProxyService.assertExpectedTokenType(token);
let newClient = this.newClients.get(token.secret);
if (!newClient) {
newClient = this.createNewClientForProxyToken(token);
this.newClients.set(token.secret, newClient);
// TODO: do we need this twice?
// this.config.eventBus.emit(PROXY_REPOSITORY_CREATED);
}
return newClient;
}
private async createClientForProxyToken(token: IApiUser): Promise<Unleash> {
const repository = new ProxyRepository(
this.config,
@ -143,6 +196,33 @@ export class ProxyService {
return client;
}
private async createNewClientForProxyToken(
token: IApiUser,
): Promise<Unleash> {
const repository = new FrontendApiRepository(
this.config,
this.globalFrontendApiCache,
token,
);
const client = new Unleash({
appName: 'proxy',
url: 'unused',
storageProvider: new InMemStorageProvider(),
disableMetrics: true,
repository,
disableAutoStart: true,
skipInstanceCountWarning: true,
});
client.on(UnleashEvents.Error, (error) => {
this.logger.error('We found an event error', error);
});
await client.start();
return client;
}
async deleteClientForProxyToken(secret: string): Promise<void> {
const clientPromise = this.clients.get(secret);
if (clientPromise) {

View File

@ -53,7 +53,8 @@ export type IFlagKey =
| 'sdkReporting'
| 'responseTimeMetricsFix'
| 'scimApi'
| 'displayEdgeBanner';
| 'displayEdgeBanner'
| 'globalFrontendApiCache';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -262,6 +263,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_RESPONSE_TIME_METRICS_FIX,
false,
),
globalFrontendApiCache: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_GLOBAL_FRONTEND_API_CACHE,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -50,6 +50,7 @@ process.nextTick(async () => {
executiveDashboard: true,
userAccessUIEnabled: true,
sdkReporting: true,
globalFrontendApiCache: true,
},
},
authentication: {