mirror of
https://github.com/Unleash/unleash.git
synced 2025-10-09 11:14:29 +02:00
Feat/exp flag loader (#1961)
* fix: remove unused exp flag * fix: remove unused flag * fix: add support for external flag resolver * fix: rename flagsresolver to flagresolver * fix: disable external flag resolver * fix: refactor a bit * fix: stop using unleash in server-dev * fix: remove userGroups flag * fix: revert bumping frontend
This commit is contained in:
parent
b3949dc9f5
commit
f3e8f723a2
@ -62,9 +62,27 @@ Object {
|
|||||||
},
|
},
|
||||||
"eventHook": undefined,
|
"eventHook": undefined,
|
||||||
"experimental": Object {
|
"experimental": Object {
|
||||||
|
"externalResolver": Object {
|
||||||
|
"isEnabled": [Function],
|
||||||
|
},
|
||||||
|
"flags": Object {
|
||||||
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
|
"anonymiseEventLog": false,
|
||||||
"batchMetrics": false,
|
"batchMetrics": false,
|
||||||
"embedProxy": false,
|
"embedProxy": false,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
"flagResolver": FlagResolver {
|
||||||
|
"experiments": Object {
|
||||||
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
|
"anonymiseEventLog": false,
|
||||||
|
"batchMetrics": false,
|
||||||
|
"embedProxy": false,
|
||||||
|
},
|
||||||
|
"externalResolver": Object {
|
||||||
|
"isEnabled": [Function],
|
||||||
|
},
|
||||||
|
},
|
||||||
"frontendApiOrigins": Array [],
|
"frontendApiOrigins": Array [],
|
||||||
"getLogger": [Function],
|
"getLogger": [Function],
|
||||||
"import": Object {
|
"import": Object {
|
||||||
@ -106,6 +124,7 @@ Object {
|
|||||||
"ui": Object {
|
"ui": Object {
|
||||||
"flags": Object {
|
"flags": Object {
|
||||||
"E": true,
|
"E": true,
|
||||||
|
"ENABLE_DARK_MODE_SUPPORT": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"versionCheck": Object {
|
"versionCheck": Object {
|
||||||
|
@ -70,7 +70,7 @@ export default async function getApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.experimental.embedProxy &&
|
config.experimental.flags.embedProxy &&
|
||||||
config.frontendApiOrigins.length > 0
|
config.frontendApiOrigins.length > 0
|
||||||
) {
|
) {
|
||||||
// Support CORS preflight requests for the frontend endpoints.
|
// Support CORS preflight requests for the frontend endpoints.
|
||||||
|
@ -34,11 +34,15 @@ import {
|
|||||||
parseEnvVarNumber,
|
parseEnvVarNumber,
|
||||||
parseEnvVarStrings,
|
parseEnvVarStrings,
|
||||||
} from './util/parseEnvVar';
|
} from './util/parseEnvVar';
|
||||||
import { IExperimentalOptions } from './experimental';
|
import {
|
||||||
|
defaultExperimentalOptions,
|
||||||
|
IExperimentalOptions,
|
||||||
|
} from './types/experimental';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SEGMENT_VALUES_LIMIT,
|
DEFAULT_SEGMENT_VALUES_LIMIT,
|
||||||
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
|
DEFAULT_STRATEGY_SEGMENTS_LIMIT,
|
||||||
} from './util/segments';
|
} from './util/segments';
|
||||||
|
import FlagResolver from './util/flag-resolver';
|
||||||
|
|
||||||
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
|
const safeToUpper = (s: string) => (s ? s.toUpperCase() : s);
|
||||||
|
|
||||||
@ -55,15 +59,12 @@ function mergeAll<T>(objects: Partial<T>[]): T {
|
|||||||
|
|
||||||
function loadExperimental(options: IUnleashOptions): IExperimentalOptions {
|
function loadExperimental(options: IUnleashOptions): IExperimentalOptions {
|
||||||
return {
|
return {
|
||||||
|
...defaultExperimentalOptions,
|
||||||
...options.experimental,
|
...options.experimental,
|
||||||
embedProxy: parseEnvVarBoolean(
|
flags: {
|
||||||
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
...defaultExperimentalOptions.flags,
|
||||||
Boolean(options.experimental?.embedProxy),
|
...options.experimental?.flags,
|
||||||
),
|
},
|
||||||
batchMetrics: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
|
|
||||||
Boolean(options.experimental?.batchMetrics),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +103,7 @@ function loadUI(options: IUnleashOptions): IUIConfig {
|
|||||||
|
|
||||||
ui.flags = {
|
ui.flags = {
|
||||||
E: true,
|
E: true,
|
||||||
|
ENABLE_DARK_MODE_SUPPORT: false,
|
||||||
};
|
};
|
||||||
return mergeAll([uiO, ui]);
|
return mergeAll([uiO, ui]);
|
||||||
}
|
}
|
||||||
@ -375,6 +377,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const experimental = loadExperimental(options);
|
const experimental = loadExperimental(options);
|
||||||
|
const flagResolver = new FlagResolver(experimental);
|
||||||
|
|
||||||
const ui = loadUI(options);
|
const ui = loadUI(options);
|
||||||
|
|
||||||
@ -434,6 +437,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
|||||||
ui,
|
ui,
|
||||||
import: importSetting,
|
import: importSetting,
|
||||||
experimental,
|
experimental,
|
||||||
|
flagResolver,
|
||||||
email,
|
email,
|
||||||
secureHeaders,
|
secureHeaders,
|
||||||
enableOAS,
|
enableOAS,
|
||||||
|
@ -45,16 +45,8 @@ export class AccessStore implements IAccessStore {
|
|||||||
|
|
||||||
private db: Knex;
|
private db: Knex;
|
||||||
|
|
||||||
private enableUserGroupPermissions: boolean;
|
constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) {
|
||||||
|
|
||||||
constructor(
|
|
||||||
db: Knex,
|
|
||||||
eventBus: EventEmitter,
|
|
||||||
getLogger: Function,
|
|
||||||
enableUserGroupPermissions: boolean,
|
|
||||||
) {
|
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.enableUserGroupPermissions = enableUserGroupPermissions;
|
|
||||||
this.logger = getLogger('access-store.ts');
|
this.logger = getLogger('access-store.ts');
|
||||||
this.timer = (action: string) =>
|
this.timer = (action: string) =>
|
||||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||||
@ -133,7 +125,6 @@ export class AccessStore implements IAccessStore {
|
|||||||
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
||||||
.where('ur.user_id', '=', userId);
|
.where('ur.user_id', '=', userId);
|
||||||
|
|
||||||
if (this.enableUserGroupPermissions) {
|
|
||||||
userPermissionQuery = userPermissionQuery.union((db) => {
|
userPermissionQuery = userPermissionQuery.union((db) => {
|
||||||
db.select(
|
db.select(
|
||||||
'project',
|
'project',
|
||||||
@ -145,15 +136,10 @@ export class AccessStore implements IAccessStore {
|
|||||||
.from<IPermissionRow>(`${T.GROUP_USER} AS gu`)
|
.from<IPermissionRow>(`${T.GROUP_USER} AS gu`)
|
||||||
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
.join(`${T.GROUPS} AS g`, 'g.id', 'gu.group_id')
|
||||||
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
|
.join(`${T.GROUP_ROLE} AS gr`, 'gu.group_id', 'gr.group_id')
|
||||||
.join(
|
.join(`${T.ROLE_PERMISSION} AS rp`, 'rp.role_id', 'gr.role_id')
|
||||||
`${T.ROLE_PERMISSION} AS rp`,
|
|
||||||
'rp.role_id',
|
|
||||||
'gr.role_id',
|
|
||||||
)
|
|
||||||
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
.join(`${T.PERMISSIONS} AS p`, 'p.id', 'rp.permission_id')
|
||||||
.where('gu.user_id', '=', userId);
|
.where('gu.user_id', '=', userId);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
const rows = await userPermissionQuery;
|
const rows = await userPermissionQuery;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
return rows.map(this.mapUserPermission);
|
return rows.map(this.mapUserPermission);
|
||||||
|
@ -57,12 +57,7 @@ export const createStores = (
|
|||||||
tagStore: new TagStore(db, eventBus, getLogger),
|
tagStore: new TagStore(db, eventBus, getLogger),
|
||||||
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
|
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
|
||||||
addonStore: new AddonStore(db, eventBus, getLogger),
|
addonStore: new AddonStore(db, eventBus, getLogger),
|
||||||
accessStore: new AccessStore(
|
accessStore: new AccessStore(db, eventBus, getLogger),
|
||||||
db,
|
|
||||||
eventBus,
|
|
||||||
getLogger,
|
|
||||||
config?.experimental?.userGroups,
|
|
||||||
),
|
|
||||||
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
|
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
|
||||||
resetTokenStore: new ResetTokenStore(db, eventBus, getLogger),
|
resetTokenStore: new ResetTokenStore(db, eventBus, getLogger),
|
||||||
sessionStore: new SessionStore(db, eventBus, getLogger),
|
sessionStore: new SessionStore(db, eventBus, getLogger),
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
export interface IExperimentalOptions {
|
|
||||||
metricsV2?: IExperimentalToggle;
|
|
||||||
clientFeatureMemoize?: IExperimentalToggle;
|
|
||||||
userGroups?: boolean;
|
|
||||||
anonymiseEventLog?: boolean;
|
|
||||||
embedProxy?: boolean;
|
|
||||||
batchMetrics?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IExperimentalToggle {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
@ -42,7 +42,8 @@ const apiAccessMiddleware = (
|
|||||||
if (
|
if (
|
||||||
(apiUser.type === CLIENT && !isClientApi(req)) ||
|
(apiUser.type === CLIENT && !isClientApi(req)) ||
|
||||||
(apiUser.type === FRONTEND && !isProxyApi(req)) ||
|
(apiUser.type === FRONTEND && !isProxyApi(req)) ||
|
||||||
(apiUser.type === FRONTEND && !experimental.embedProxy)
|
(apiUser.type === FRONTEND &&
|
||||||
|
!experimental.flags.embedProxy)
|
||||||
) {
|
) {
|
||||||
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
|
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
|
||||||
return;
|
return;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { AuthedRequest } from '../../types/core';
|
||||||
import { IUnleashServices } from '../../types/services';
|
import { IUnleashServices } from '../../types/services';
|
||||||
import { IAuthType, IUnleashConfig } from '../../types/option';
|
import { IAuthType, IUnleashConfig } from '../../types/option';
|
||||||
import version from '../../util/version';
|
import version from '../../util/version';
|
||||||
@ -66,7 +67,7 @@ class ConfigController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUIConfig(
|
async getUIConfig(
|
||||||
req: Request,
|
req: AuthedRequest,
|
||||||
res: Response<UiConfigSchema>,
|
res: Response<UiConfigSchema>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const simpleAuthSettings =
|
const simpleAuthSettings =
|
||||||
@ -76,8 +77,14 @@ class ConfigController extends Controller {
|
|||||||
simpleAuthSettings?.disabled ||
|
simpleAuthSettings?.disabled ||
|
||||||
this.config.authentication.type == IAuthType.NONE;
|
this.config.authentication.type == IAuthType.NONE;
|
||||||
|
|
||||||
|
const expFlags = this.config.flagResolver.getAll({
|
||||||
|
email: req.user.email,
|
||||||
|
});
|
||||||
|
const flags = { ...this.config.ui.flags, ...expFlags };
|
||||||
|
|
||||||
const response: UiConfigSchema = {
|
const response: UiConfigSchema = {
|
||||||
...this.config.ui,
|
...this.config.ui,
|
||||||
|
flags,
|
||||||
version,
|
version,
|
||||||
emailEnabled: this.emailService.isEnabled(),
|
emailEnabled: this.emailService.isEnabled(),
|
||||||
unleashUrl: this.config.server.unleashUrl,
|
unleashUrl: this.config.server.unleashUrl,
|
||||||
@ -87,7 +94,7 @@ class ConfigController extends Controller {
|
|||||||
strategySegmentsLimit: this.config.strategySegmentsLimit,
|
strategySegmentsLimit: this.config.strategySegmentsLimit,
|
||||||
versionInfo: this.versionService.getVersionInfo(),
|
versionInfo: this.versionService.getVersionInfo(),
|
||||||
disablePasswordAuth,
|
disablePasswordAuth,
|
||||||
embedProxy: this.config.experimental.embedProxy,
|
embedProxy: this.config.experimental.flags.embedProxy,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
|
@ -21,12 +21,13 @@ import {
|
|||||||
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
|
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
|
||||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
||||||
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
||||||
|
import { IFlagResolver } from '../../types/experimental';
|
||||||
|
|
||||||
const version = 1;
|
const version = 1;
|
||||||
export default class EventController extends Controller {
|
export default class EventController extends Controller {
|
||||||
private eventService: EventService;
|
private eventService: EventService;
|
||||||
|
|
||||||
private anonymise: boolean = false;
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private openApiService: OpenApiService;
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ export default class EventController extends Controller {
|
|||||||
) {
|
) {
|
||||||
super(config);
|
super(config);
|
||||||
this.eventService = eventService;
|
this.eventService = eventService;
|
||||||
this.anonymise = config.experimental?.anonymiseEventLog;
|
this.flagResolver = config.flagResolver;
|
||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
@ -106,7 +107,7 @@ export default class EventController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maybeAnonymiseEvents(events: IEvent[]): IEvent[] {
|
maybeAnonymiseEvents(events: IEvent[]): IEvent[] {
|
||||||
if (this.anonymise) {
|
if (this.flagResolver.isEnabled('anonymiseEventLog')) {
|
||||||
return events.map((e: IEvent) => ({
|
return events.map((e: IEvent) => ({
|
||||||
...e,
|
...e,
|
||||||
createdBy: anonymise(e.createdBy),
|
createdBy: anonymise(e.createdBy),
|
||||||
|
@ -12,7 +12,7 @@ async function getSetup(anonymise: boolean = false) {
|
|||||||
const stores = createStores();
|
const stores = createStores();
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
server: { baseUriPath: base },
|
server: { baseUriPath: base },
|
||||||
experimental: { anonymiseEventLog: anonymise },
|
experimental: { flags: { anonymiseEventLog: anonymise } },
|
||||||
});
|
});
|
||||||
const services = createServices(stores, config);
|
const services = createServices(stores, config);
|
||||||
const app = await getApp(config, stores, services);
|
const app = await getApp(config, stores, services);
|
||||||
|
@ -37,9 +37,10 @@ import {
|
|||||||
usersGroupsBaseSchema,
|
usersGroupsBaseSchema,
|
||||||
} from '../../openapi/spec/users-groups-base-schema';
|
} from '../../openapi/spec/users-groups-base-schema';
|
||||||
import { IGroup } from '../../types/group';
|
import { IGroup } from '../../types/group';
|
||||||
|
import { IFlagResolver } from '../../types/experimental';
|
||||||
|
|
||||||
export default class UserAdminController extends Controller {
|
export default class UserAdminController extends Controller {
|
||||||
private anonymise: boolean = false;
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private userService: UserService;
|
private userService: UserService;
|
||||||
|
|
||||||
@ -90,7 +91,7 @@ export default class UserAdminController extends Controller {
|
|||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.logger = config.getLogger('routes/user-controller.ts');
|
this.logger = config.getLogger('routes/user-controller.ts');
|
||||||
this.unleashUrl = config.server.unleashUrl;
|
this.unleashUrl = config.server.unleashUrl;
|
||||||
this.anonymise = config.experimental?.anonymiseEventLog;
|
this.flagResolver = config.flagResolver;
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@ -294,7 +295,7 @@ export default class UserAdminController extends Controller {
|
|||||||
typeof q === 'string' && q.length > 1
|
typeof q === 'string' && q.length > 1
|
||||||
? await this.userService.search(q)
|
? await this.userService.search(q)
|
||||||
: [];
|
: [];
|
||||||
if (this.anonymise) {
|
if (this.flagResolver.isEnabled('anonymiseEventLog')) {
|
||||||
users = this.anonymiseUsers(users);
|
users = this.anonymiseUsers(users);
|
||||||
}
|
}
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
|
@ -82,9 +82,7 @@ test('should accept client metrics with yes/no', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should accept client metrics with yes/no with metricsV2', async () => {
|
test('should accept client metrics with yes/no with metricsV2', async () => {
|
||||||
const testRunner = await getSetup({
|
const testRunner = await getSetup();
|
||||||
experimental: { metricsV2: { enabled: true } },
|
|
||||||
});
|
|
||||||
await testRunner.request
|
await testRunner.request
|
||||||
.post('/api/client/metrics')
|
.post('/api/client/metrics')
|
||||||
.send({
|
.send({
|
||||||
|
@ -28,7 +28,7 @@ class IndexRouter extends Controller {
|
|||||||
this.use('/api/admin', new AdminApi(config, services).router);
|
this.use('/api/admin', new AdminApi(config, services).router);
|
||||||
this.use('/api/client', new ClientApi(config, services).router);
|
this.use('/api/client', new ClientApi(config, services).router);
|
||||||
|
|
||||||
if (config.experimental.embedProxy) {
|
if (config.experimental.flags.embedProxy) {
|
||||||
this.use(
|
this.use(
|
||||||
'/api/frontend',
|
'/api/frontend',
|
||||||
new ProxyController(config, services).router,
|
new ProxyController(config, services).router,
|
||||||
|
@ -16,7 +16,6 @@ import ApiUser from '../../types/api-user';
|
|||||||
import { ALL } from '../../types/models/api-token';
|
import { ALL } from '../../types/models/api-token';
|
||||||
import User from '../../types/user';
|
import User from '../../types/user';
|
||||||
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
|
import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics';
|
||||||
import { IExperimentalOptions } from '../../experimental';
|
|
||||||
|
|
||||||
export default class ClientMetricsServiceV2 {
|
export default class ClientMetricsServiceV2 {
|
||||||
private timers: NodeJS.Timeout[] = [];
|
private timers: NodeJS.Timeout[] = [];
|
||||||
@ -27,7 +26,7 @@ export default class ClientMetricsServiceV2 {
|
|||||||
|
|
||||||
private featureToggleStore: IFeatureToggleStore;
|
private featureToggleStore: IFeatureToggleStore;
|
||||||
|
|
||||||
private experimental: IExperimentalOptions;
|
private batchMetricsEnabled: boolean;
|
||||||
|
|
||||||
private eventBus: EventEmitter;
|
private eventBus: EventEmitter;
|
||||||
|
|
||||||
@ -47,13 +46,13 @@ export default class ClientMetricsServiceV2 {
|
|||||||
) {
|
) {
|
||||||
this.featureToggleStore = featureToggleStore;
|
this.featureToggleStore = featureToggleStore;
|
||||||
this.clientMetricsStoreV2 = clientMetricsStoreV2;
|
this.clientMetricsStoreV2 = clientMetricsStoreV2;
|
||||||
this.experimental = experimental;
|
this.batchMetricsEnabled = experimental.flags.batchMetrics;
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.logger = getLogger(
|
this.logger = getLogger(
|
||||||
'/services/client-metrics/client-metrics-service-v2.ts',
|
'/services/client-metrics/client-metrics-service-v2.ts',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.experimental.batchMetrics) {
|
if (this.batchMetricsEnabled) {
|
||||||
this.timers.push(
|
this.timers.push(
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.bulkAdd().catch(console.error);
|
this.bulkAdd().catch(console.error);
|
||||||
@ -91,7 +90,7 @@ export default class ClientMetricsServiceV2 {
|
|||||||
}))
|
}))
|
||||||
.filter((item) => !(item.yes === 0 && item.no === 0));
|
.filter((item) => !(item.yes === 0 && item.no === 0));
|
||||||
|
|
||||||
if (this.experimental.batchMetrics) {
|
if (this.batchMetricsEnabled) {
|
||||||
this.unsavedMetrics = collapseHourlyMetrics([
|
this.unsavedMetrics = collapseHourlyMetrics([
|
||||||
...this.unsavedMetrics,
|
...this.unsavedMetrics,
|
||||||
...clientMetrics,
|
...clientMetrics,
|
||||||
@ -104,7 +103,7 @@ export default class ClientMetricsServiceV2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async bulkAdd(): Promise<void> {
|
async bulkAdd(): Promise<void> {
|
||||||
if (this.experimental.batchMetrics && this.unsavedMetrics.length > 0) {
|
if (this.batchMetricsEnabled && this.unsavedMetrics.length > 0) {
|
||||||
// Make a copy of `unsavedMetrics` in case new metrics
|
// Make a copy of `unsavedMetrics` in case new metrics
|
||||||
// arrive while awaiting `batchInsertMetrics`.
|
// arrive while awaiting `batchInsertMetrics`.
|
||||||
const copy = [...this.unsavedMetrics];
|
const copy = [...this.unsavedMetrics];
|
||||||
|
43
src/lib/types/experimental.ts
Normal file
43
src/lib/types/experimental.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { parseEnvVarBoolean } from '../util/parseEnvVar';
|
||||||
|
|
||||||
|
export type IFlags = Partial<Record<string, boolean>>;
|
||||||
|
|
||||||
|
export const defaultExperimentalOptions = {
|
||||||
|
flags: {
|
||||||
|
ENABLE_DARK_MODE_SUPPORT: false,
|
||||||
|
anonymiseEventLog: false,
|
||||||
|
embedProxy: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
batchMetrics: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
externalResolver: { isEnabled: (): boolean => false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IExperimentalOptions {
|
||||||
|
flags: {
|
||||||
|
[key: string]: boolean;
|
||||||
|
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||||
|
embedProxy?: boolean;
|
||||||
|
batchMetrics?: boolean;
|
||||||
|
anonymiseEventLog?: boolean;
|
||||||
|
};
|
||||||
|
externalResolver: IExternalFlagResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFlagContext {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFlagResolver {
|
||||||
|
getAll: (context?: IFlagContext) => IFlags;
|
||||||
|
isEnabled: (expName: string, context?: IFlagContext) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExternalFlagResolver {
|
||||||
|
isEnabled: (flagName: string, context?: IFlagContext) => boolean;
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { LogLevel, LogProvider } from '../logger';
|
import { LogLevel, LogProvider } from '../logger';
|
||||||
import { ILegacyApiTokenCreate } from './models/api-token';
|
import { ILegacyApiTokenCreate } from './models/api-token';
|
||||||
import { IExperimentalOptions } from '../experimental';
|
import { IFlagResolver, IExperimentalOptions, IFlags } from './experimental';
|
||||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
|
|
||||||
export type EventHook = (eventName: string, data: object) => void;
|
export type EventHook = (eventName: string, data: object) => void;
|
||||||
@ -102,7 +102,7 @@ export interface IUnleashOptions {
|
|||||||
authentication?: Partial<IAuthOption>;
|
authentication?: Partial<IAuthOption>;
|
||||||
ui?: object;
|
ui?: object;
|
||||||
import?: Partial<IImportOption>;
|
import?: Partial<IImportOption>;
|
||||||
experimental?: IExperimentalOptions;
|
experimental?: Partial<IExperimentalOptions>;
|
||||||
email?: Partial<IEmailOption>;
|
email?: Partial<IEmailOption>;
|
||||||
secureHeaders?: boolean;
|
secureHeaders?: boolean;
|
||||||
additionalCspAllowedDomains?: ICspDomainOptions;
|
additionalCspAllowedDomains?: ICspDomainOptions;
|
||||||
@ -139,7 +139,6 @@ export interface IListeningHost {
|
|||||||
export interface IUIConfig {
|
export interface IUIConfig {
|
||||||
slogan?: string;
|
slogan?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
flags?: { [key: string]: boolean };
|
|
||||||
links?: [
|
links?: [
|
||||||
{
|
{
|
||||||
value: string;
|
value: string;
|
||||||
@ -148,6 +147,7 @@ export interface IUIConfig {
|
|||||||
title: string;
|
title: string;
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
flags?: IFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICspDomainOptions {
|
export interface ICspDomainOptions {
|
||||||
@ -177,6 +177,7 @@ export interface IUnleashConfig {
|
|||||||
ui: IUIConfig;
|
ui: IUIConfig;
|
||||||
import: IImportOption;
|
import: IImportOption;
|
||||||
experimental?: IExperimentalOptions;
|
experimental?: IExperimentalOptions;
|
||||||
|
flagResolver: IFlagResolver;
|
||||||
email: IEmailOption;
|
email: IEmailOption;
|
||||||
secureHeaders: boolean;
|
secureHeaders: boolean;
|
||||||
additionalCspAllowedDomains: ICspDomainConfig;
|
additionalCspAllowedDomains: ICspDomainConfig;
|
||||||
|
101
src/lib/util/flag-resolver.test.ts
Normal file
101
src/lib/util/flag-resolver.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { defaultExperimentalOptions } from '../types/experimental';
|
||||||
|
import FlagResolver from './flag-resolver';
|
||||||
|
|
||||||
|
test('should produce empty exposed flags', () => {
|
||||||
|
const resolver = new FlagResolver(defaultExperimentalOptions);
|
||||||
|
|
||||||
|
const result = resolver.getAll();
|
||||||
|
|
||||||
|
expect(result.ENABLE_DARK_MODE_SUPPORT).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should produce UI flags with extra dynamic flags', () => {
|
||||||
|
const config = {
|
||||||
|
...defaultExperimentalOptions,
|
||||||
|
flags: { extraFlag: false },
|
||||||
|
};
|
||||||
|
const resolver = new FlagResolver(config);
|
||||||
|
const result = resolver.getAll();
|
||||||
|
|
||||||
|
expect(result.extraFlag).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use external resolver for dynamic flags', () => {
|
||||||
|
const externalResolver = {
|
||||||
|
isEnabled: (name: string) => {
|
||||||
|
if (name === 'extraFlag') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = new FlagResolver({
|
||||||
|
flags: {
|
||||||
|
extraFlag: false,
|
||||||
|
},
|
||||||
|
externalResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolver.getAll();
|
||||||
|
|
||||||
|
expect(result.extraFlag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not use external resolver for enabled experiments', () => {
|
||||||
|
const externalResolver = {
|
||||||
|
isEnabled: () => {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = new FlagResolver({
|
||||||
|
flags: {
|
||||||
|
should_be_enabled: true,
|
||||||
|
extraFlag: false,
|
||||||
|
},
|
||||||
|
externalResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = resolver.getAll();
|
||||||
|
|
||||||
|
expect(result.should_be_enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load experimental flags', () => {
|
||||||
|
const externalResolver = {
|
||||||
|
isEnabled: () => {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolver = new FlagResolver({
|
||||||
|
flags: {
|
||||||
|
extraFlag: false,
|
||||||
|
someFlag: true,
|
||||||
|
},
|
||||||
|
externalResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver.isEnabled('someFlag')).toBe(true);
|
||||||
|
expect(resolver.isEnabled('extraFlag')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load experimental flags from external provider', () => {
|
||||||
|
const externalResolver = {
|
||||||
|
isEnabled: (name: string) => {
|
||||||
|
if (name === 'extraFlag') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = new FlagResolver({
|
||||||
|
flags: {
|
||||||
|
extraFlag: false,
|
||||||
|
someFlag: true,
|
||||||
|
},
|
||||||
|
externalResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolver.isEnabled('someFlag')).toBe(true);
|
||||||
|
expect(resolver.isEnabled('extraFlag')).toBe(true);
|
||||||
|
});
|
38
src/lib/util/flag-resolver.ts
Normal file
38
src/lib/util/flag-resolver.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
IExperimentalOptions,
|
||||||
|
IExternalFlagResolver,
|
||||||
|
IFlagContext,
|
||||||
|
IFlags,
|
||||||
|
IFlagResolver,
|
||||||
|
} from '../types/experimental';
|
||||||
|
export default class FlagResolver implements IFlagResolver {
|
||||||
|
private experiments: IFlags;
|
||||||
|
|
||||||
|
private externalResolver: IExternalFlagResolver;
|
||||||
|
|
||||||
|
constructor(expOpt: IExperimentalOptions) {
|
||||||
|
this.experiments = expOpt.flags;
|
||||||
|
this.externalResolver = expOpt.externalResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(context?: IFlagContext): IFlags {
|
||||||
|
const flags: IFlags = { ...this.experiments };
|
||||||
|
|
||||||
|
Object.keys(flags).forEach((flagName) => {
|
||||||
|
if (!this.experiments[flagName])
|
||||||
|
flags[flagName] = this.externalResolver.isEnabled(
|
||||||
|
flagName,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(expName: string, context?: IFlagContext): boolean {
|
||||||
|
if (this.experiments[expName]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.externalResolver.isEnabled(expName, context);
|
||||||
|
}
|
||||||
|
}
|
@ -31,11 +31,12 @@ process.nextTick(async () => {
|
|||||||
enable: false,
|
enable: false,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
metricsV2: { enabled: true },
|
// externalResolver: unleash,
|
||||||
anonymiseEventLog: false,
|
flags: {
|
||||||
userGroups: true,
|
|
||||||
embedProxy: true,
|
embedProxy: true,
|
||||||
batchMetrics: true,
|
batchMetrics: true,
|
||||||
|
anonymiseEventLog: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
initApiTokens: [
|
initApiTokens: [
|
||||||
|
@ -23,10 +23,11 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
userGroups: true,
|
flags: {
|
||||||
embedProxy: true,
|
embedProxy: true,
|
||||||
batchMetrics: true,
|
batchMetrics: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const options = mergeAll<IUnleashOptions>([testConfig, config]);
|
const options = mergeAll<IUnleashOptions>([testConfig, config]);
|
||||||
return createConfig(options);
|
return createConfig(options);
|
||||||
|
@ -9,9 +9,7 @@ let db: ITestDb;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('client_metrics_serial', getLogger);
|
db = await dbInit('client_metrics_serial', getLogger);
|
||||||
app = await setupAppWithCustomConfig(db.stores, {
|
app = await setupAppWithCustomConfig(db.stores, {});
|
||||||
experimental: { metricsV2: { enabled: true } },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
@ -11,9 +11,7 @@ let defaultToken;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('metrics_two_api_client', getLogger);
|
db = await dbInit('metrics_two_api_client', getLogger);
|
||||||
app = await setupAppWithAuth(db.stores, {
|
app = await setupAppWithAuth(db.stores, {});
|
||||||
experimental: { metricsV2: { enabled: true } },
|
|
||||||
});
|
|
||||||
defaultToken = await app.services.apiTokenService.createApiToken({
|
defaultToken = await app.services.apiTokenService.createApiToken({
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
project: 'default',
|
project: 'default',
|
||||||
|
Loading…
Reference in New Issue
Block a user