1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-21 13:47:39 +02:00

chore: unknown flags (#9837)

https://linear.app/unleash/issue/2-3406/hold-unknown-flags-in-memory-and-show-them-in-the-ui-somehow

This PR introduces a suggestion for a “unknown flags” feature.

When clients report metrics for flags that don’t exist in Unleash (e.g.
due to typos), we now track a limited set of these unknown flag names
along with the appnames that reported them. The goal is to help users
identify and clean up incorrect flag usage across their apps.

We store up to 10 unknown flag + appName combinations, keeping only the
most recent reports. Data is collected in-memory and flushed
periodically to the DB, with deduplication and merging to ensure we
don’t exceed the cap even across pods.

We were especially careful to make this implementation defensive, as
unknown flags could be reported in very high volumes. Writes are
batched, deduplicated, and hard-capped to avoid DB pressure.

No UI has been added yet — this is backend-only for now and intended as
a step toward better visibility into client misconfigurations.

I would suggest starting with a simple banner that opens a dialog
showing the list of unknown flags and which apps reported them.

<img width="497" alt="image"
src="https://github.com/user-attachments/assets/b7348e0d-0163-4be4-a7f8-c072e8464331"
/>
This commit is contained in:
Nuno Góis 2025-05-07 11:48:36 +01:00 committed by GitHub
parent 2b73b17579
commit eb238f502a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 517 additions and 13 deletions

View File

@ -66,6 +66,7 @@ import { UserSubscriptionsReadModel } from '../features/user-subscriptions/user-
import { UniqueConnectionStore } from '../features/unique-connection/unique-connection-store';
import { UniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model';
import { FeatureLinkStore } from '../features/feature-links/feature-link-store';
import { UnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store';
export const createStores = (
config: IUnleashConfig,
@ -203,6 +204,7 @@ export const createStores = (
releasePlanMilestoneStrategyStore:
new ReleasePlanMilestoneStrategyStore(db, config),
featureLinkStore: new FeatureLinkStore(db, config),
unknownFlagsStore: new UnknownFlagsStore(db),
};
};

View File

@ -11,6 +11,7 @@ import type {
} from '../../../../lib/types';
import { endOfDay, startOfHour, subDays, subHours } from 'date-fns';
import type { IClientMetricsEnv } from './client-metrics-store-v2-type';
import { UnknownFlagsService } from '../unknown-flags/unknown-flags-service';
function initClientMetrics(flagEnabled = true) {
const stores = createStores();
@ -35,8 +36,19 @@ function initClientMetrics(flagEnabled = true) {
config,
);
lastSeenService.updateLastSeen = jest.fn();
const unknownFlagsService = new UnknownFlagsService(
{
unknownFlagsStore: stores.unknownFlagsStore,
},
config,
);
const service = new ClientMetricsServiceV2(stores, config, lastSeenService);
const service = new ClientMetricsServiceV2(
stores,
config,
lastSeenService,
unknownFlagsService,
);
return { clientMetricsService: service, eventBus, lastSeenService };
}
@ -161,10 +173,12 @@ test('get daily client metrics for a toggle', async () => {
getLogger() {},
} as unknown as IUnleashConfig;
const lastSeenService = {} as LastSeenService;
const unknownFlagsService = {} as UnknownFlagsService;
const service = new ClientMetricsServiceV2(
{ clientMetricsStoreV2 },
config,
lastSeenService,
unknownFlagsService,
);
const metrics = await service.getClientMetricsForToggle('feature', 3 * 24);
@ -217,10 +231,12 @@ test('get hourly client metrics for a toggle', async () => {
getLogger() {},
} as unknown as IUnleashConfig;
const lastSeenService = {} as LastSeenService;
const unknownFlagsService = {} as UnknownFlagsService;
const service = new ClientMetricsServiceV2(
{ clientMetricsStoreV2 },
config,
lastSeenService,
unknownFlagsService,
);
const metrics = await service.getClientMetricsForToggle('feature', 2);
@ -287,10 +303,12 @@ const setupMetricsService = ({
},
} as unknown as IUnleashConfig;
const lastSeenService = {} as LastSeenService;
const unknownFlagsService = {} as UnknownFlagsService;
const service = new ClientMetricsServiceV2(
{ clientMetricsStoreV2 },
config,
lastSeenService,
unknownFlagsService,
);
return {
service,

View File

@ -26,6 +26,10 @@ import {
import type { ClientMetricsSchema } from '../../../../lib/openapi';
import { nameSchema } from '../../../schema/feature-schema';
import memoizee from 'memoizee';
import {
MAX_UNKNOWN_FLAGS,
type UnknownFlagsService,
} from '../unknown-flags/unknown-flags-service';
export default class ClientMetricsServiceV2 {
private config: IUnleashConfig;
@ -36,6 +40,8 @@ export default class ClientMetricsServiceV2 {
private lastSeenService: LastSeenService;
private unknownFlagsService: UnknownFlagsService;
private flagResolver: Pick<IFlagResolver, 'isEnabled' | 'getVariant'>;
private logger: Logger;
@ -46,9 +52,11 @@ export default class ClientMetricsServiceV2 {
{ clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
config: IUnleashConfig,
lastSeenService: LastSeenService,
unknownFlagsService: UnknownFlagsService,
) {
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.lastSeenService = lastSeenService;
this.unknownFlagsService = unknownFlagsService;
this.config = config;
this.logger = config.getLogger(
'/services/client-metrics/client-metrics-service-v2.ts',
@ -113,25 +121,55 @@ export default class ClientMetricsServiceV2 {
}
}
async filterExistingToggleNames(toggleNames: string[]): Promise<string[]> {
if (this.flagResolver.isEnabled('filterExistingFlagNames')) {
async filterExistingToggleNames(toggleNames: string[]): Promise<{
validatedToggleNames: string[];
unknownToggleNames: string[];
}> {
let toggleNamesToValidate: string[] = toggleNames;
let unknownToggleNames: string[] = [];
const shouldFilter = this.flagResolver.isEnabled(
'filterExistingFlagNames',
);
const shouldReport = this.flagResolver.isEnabled('reportUnknownFlags');
if (shouldFilter || shouldReport) {
try {
const validNames = await this.cachedFeatureNames();
const existingFlags = await this.cachedFeatureNames();
const existingNames = toggleNames.filter((name) =>
validNames.includes(name),
existingFlags.includes(name),
);
if (existingNames.length !== toggleNames.length) {
this.logger.info(
`Filtered out ${toggleNames.length - existingNames.length} toggles with non-existing names`,
const nonExistingNames = toggleNames.filter(
(name) => !existingFlags.includes(name),
);
if (shouldFilter) {
toggleNamesToValidate = existingNames;
if (existingNames.length !== toggleNames.length) {
this.logger.info(
`Filtered out ${toggleNames.length - existingNames.length} toggles with non-existing names`,
);
}
}
if (shouldReport) {
unknownToggleNames = nonExistingNames.slice(
0,
MAX_UNKNOWN_FLAGS,
);
}
return this.filterValidToggleNames(existingNames);
} catch (e) {
this.logger.error(e);
}
}
return this.filterValidToggleNames(toggleNames);
const validatedToggleNames = await this.filterValidToggleNames(
toggleNamesToValidate,
);
return { validatedToggleNames, unknownToggleNames };
}
async filterValidToggleNames(toggleNames: string[]): Promise<string[]> {
@ -181,7 +219,7 @@ export default class ClientMetricsServiceV2 {
),
);
const validatedToggleNames =
const { validatedToggleNames, unknownToggleNames } =
await this.filterExistingToggleNames(toggleNames);
this.logger.debug(
@ -204,6 +242,15 @@ export default class ClientMetricsServiceV2 {
this.config.eventBus.emit(CLIENT_REGISTER, heartbeatEvent);
}
if (unknownToggleNames.length > 0) {
const unknownFlags = unknownToggleNames.map((name) => ({
name,
appName: value.appName,
seenAt: value.bucket.stop,
}));
this.unknownFlagsService.register(unknownFlags);
}
if (validatedToggleNames.length > 0) {
const clientMetrics: IClientMetricsEnv[] = validatedToggleNames.map(
(name) => ({

View File

@ -0,0 +1,37 @@
import type { IUnknownFlagsStore, UnknownFlag } from './unknown-flags-store';
export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
private unknownFlagMap = new Map<string, UnknownFlag>();
private getKey(flag: UnknownFlag): string {
return `${flag.name}:${flag.appName}`;
}
async replaceAll(flags: UnknownFlag[]): Promise<void> {
this.unknownFlagMap.clear();
for (const flag of flags) {
this.unknownFlagMap.set(this.getKey(flag), flag);
}
}
async getAll(): Promise<UnknownFlag[]> {
return Array.from(this.unknownFlagMap.values());
}
async clear(hoursAgo: number): Promise<void> {
const cutoff = Date.now() - hoursAgo * 60 * 60 * 1000;
for (const [key, flag] of this.unknownFlagMap.entries()) {
if (flag.seenAt.getTime() < cutoff) {
this.unknownFlagMap.delete(key);
}
}
}
async deleteAll(): Promise<void> {
this.unknownFlagMap.clear();
}
async count(): Promise<number> {
return this.unknownFlagMap.size;
}
}

View File

@ -0,0 +1,74 @@
import type { Response } from 'express';
import {
unknownFlagsResponseSchema,
type UnknownFlagsResponseSchema,
} from '../../../openapi';
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
import Controller from '../../../routes/controller';
import type { IAuthRequest } from '../../../routes/unleash-types';
import type { OpenApiService } from '../../../services/openapi-service';
import type { IFlagResolver } from '../../../types/experimental';
import type { IUnleashConfig } from '../../../types/option';
import { NONE } from '../../../types/permissions';
import { serializeDates } from '../../../types/serialize-dates';
import type { IUnleashServices } from '../../../types/services';
import type { UnknownFlagsService } from './unknown-flags-service';
import { NotFoundError } from '../../../error';
export default class UnknownFlagsController extends Controller {
private unknownFlagsService: UnknownFlagsService;
private flagResolver: IFlagResolver;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
unknownFlagsService,
openApiService,
}: Pick<IUnleashServices, 'unknownFlagsService' | 'openApiService'>,
) {
super(config);
this.unknownFlagsService = unknownFlagsService;
this.flagResolver = config.flagResolver;
this.openApiService = openApiService;
this.route({
method: 'get',
path: '',
handler: this.getUnknownFlags,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getUnknownFlags',
tags: ['Unstable'],
summary: 'Get latest reported unknown flag names',
description:
'Returns a list of unknown flag names reported in the last 24 hours, if any. Maximum of 10.',
responses: {
200: createResponseSchema('unknownFlagsResponseSchema'),
},
}),
],
});
}
async getUnknownFlags(
_: IAuthRequest,
res: Response<UnknownFlagsResponseSchema>,
): Promise<void> {
if (!this.flagResolver.isEnabled('reportUnknownFlags')) {
throw new NotFoundError();
}
const unknownFlags =
await this.unknownFlagsService.getGroupedUnknownFlags();
this.openApiService.respondWithValidation(
200,
res,
unknownFlagsResponseSchema.$id,
serializeDates({ unknownFlags }),
);
}
}

View File

@ -0,0 +1,107 @@
import type { Logger } from '../../../logger';
import type {
IFlagResolver,
IUnknownFlagsStore,
IUnleashConfig,
} from '../../../types';
import type { IUnleashStores } from '../../../types';
import type { UnknownFlag } from './unknown-flags-store';
export const MAX_UNKNOWN_FLAGS = 10;
export class UnknownFlagsService {
private logger: Logger;
private flagResolver: IFlagResolver;
private unknownFlagsStore: IUnknownFlagsStore;
private unknownFlagsCache: Map<string, UnknownFlag>;
constructor(
{ unknownFlagsStore }: Pick<IUnleashStores, 'unknownFlagsStore'>,
config: IUnleashConfig,
) {
this.unknownFlagsStore = unknownFlagsStore;
this.flagResolver = config.flagResolver;
this.logger = config.getLogger(
'/features/metrics/unknown-flags/unknown-flags-service.ts',
);
this.unknownFlagsCache = new Map<string, UnknownFlag>();
}
private getKey(flag: UnknownFlag) {
return `${flag.name}:${flag.appName}`;
}
register(unknownFlags: UnknownFlag[]) {
for (const flag of unknownFlags) {
const key = this.getKey(flag);
if (this.unknownFlagsCache.has(key)) {
this.unknownFlagsCache.set(key, flag);
continue;
}
if (this.unknownFlagsCache.size >= MAX_UNKNOWN_FLAGS) {
const oldestKey = [...this.unknownFlagsCache.entries()].sort(
(a, b) => a[1].seenAt.getTime() - b[1].seenAt.getTime(),
)[0][0];
this.unknownFlagsCache.delete(oldestKey);
}
this.unknownFlagsCache.set(key, flag);
}
}
async flush(): Promise<void> {
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return;
if (this.unknownFlagsCache.size === 0) return;
const existing = await this.unknownFlagsStore.getAll();
const cached = Array.from(this.unknownFlagsCache.values());
const merged = [...existing, ...cached];
const mergedMap = new Map<string, UnknownFlag>();
for (const flag of merged) {
const key = this.getKey(flag);
const existing = mergedMap.get(key);
if (!existing || flag.seenAt > existing.seenAt) {
mergedMap.set(key, flag);
}
}
const latest = Array.from(mergedMap.values())
.sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime())
.slice(0, MAX_UNKNOWN_FLAGS);
await this.unknownFlagsStore.replaceAll(latest);
this.unknownFlagsCache.clear();
}
async getGroupedUnknownFlags(): Promise<
{ name: string; reportedBy: { appName: string; seenAt: Date }[] }[]
> {
const unknownFlags = await this.unknownFlagsStore.getAll();
const grouped = new Map<string, { appName: string; seenAt: Date }[]>();
for (const { name, appName, seenAt } of unknownFlags) {
if (!grouped.has(name)) {
grouped.set(name, []);
}
grouped.get(name)!.push({ appName, seenAt });
}
return Array.from(grouped.entries()).map(([name, reportedBy]) => ({
name,
reportedBy,
}));
}
async clear(hoursAgo: number) {
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return;
return this.unknownFlagsStore.clear(hoursAgo);
}
}

View File

@ -0,0 +1,67 @@
import type { Db } from '../../../db/db';
import { MAX_UNKNOWN_FLAGS } from './unknown-flags-service';
const TABLE = 'unknown_flags';
export type UnknownFlag = {
name: string;
appName: string;
seenAt: Date;
};
export interface IUnknownFlagsStore {
replaceAll(flags: UnknownFlag[]): Promise<void>;
getAll(): Promise<UnknownFlag[]>;
clear(hoursAgo: number): Promise<void>;
deleteAll(): Promise<void>;
count(): Promise<number>;
}
export class UnknownFlagsStore implements IUnknownFlagsStore {
private db: Db;
constructor(db: Db) {
this.db = db;
}
async replaceAll(flags: UnknownFlag[]): Promise<void> {
await this.db.transaction(async (tx) => {
await tx(TABLE).delete();
if (flags.length > 0) {
const rows = flags.map((flag) => ({
name: flag.name,
app_name: flag.appName,
seen_at: flag.seenAt,
}));
await tx(TABLE).insert(rows);
}
});
}
async getAll(): Promise<UnknownFlag[]> {
const rows = await this.db(TABLE)
.select('name', 'app_name', 'seen_at')
.orderBy('seen_at', 'desc')
.limit(MAX_UNKNOWN_FLAGS);
return rows.map((row) => ({
name: row.name,
appName: row.app_name,
seenAt: new Date(row.seen_at),
}));
}
async clear(hoursAgo: number): Promise<void> {
return this.db(TABLE)
.whereRaw(`seen_at <= NOW() - INTERVAL '${hoursAgo} hours'`)
.del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).delete();
}
async count(): Promise<number> {
const row = await this.db(TABLE).count('* as count').first();
return Number(row?.count ?? 0);
}
}

View File

@ -33,6 +33,7 @@ export const scheduleServices = async (
clientMetricsServiceV2,
integrationEventsService,
uniqueConnectionService,
unknownFlagsService,
} = services;
schedulerService.schedule(
@ -194,4 +195,16 @@ export const scheduleServices = async (
minutesToMilliseconds(10),
'uniqueConnectionService',
);
schedulerService.schedule(
unknownFlagsService.flush.bind(unknownFlagsService),
minutesToMilliseconds(2),
'flushUnknownFlags',
);
schedulerService.schedule(
unknownFlagsService.clear.bind(unknownFlagsService, 24),
hoursToMilliseconds(24),
'clearUnknownFlags',
);
};

View File

@ -736,6 +736,11 @@ export function registerPrometheusMetrics(
labelNames: ['result', 'destination'],
});
const unknownFlagsGauge = createGauge({
name: 'unknown_flags',
help: 'Number of unknown flags reported in the last 24 hours, if any. Maximum of 10.',
});
// register event listeners
eventBus.on(
events.EXCEEDS_LIMIT,
@ -1136,6 +1141,10 @@ export function registerPrometheusMetrics(
productionChanges60.set(productionChanges.last60);
productionChanges90.reset();
productionChanges90.set(productionChanges.last90);
const unknownFlags = await stores.unknownFlagsStore.count();
unknownFlagsGauge.reset();
unknownFlagsGauge.set(unknownFlags);
} catch (e) {}
},
};

View File

@ -200,6 +200,8 @@ export * from './toggle-maintenance-schema';
export * from './token-string-list-schema';
export * from './token-user-schema';
export * from './ui-config-schema';
export * from './unknown-flag-schema';
export * from './unknown-flags-response-schema';
export * from './update-api-token-schema';
export * from './update-context-field-schema';
export * from './update-feature-schema';

View File

@ -0,0 +1,44 @@
import type { FromSchema } from 'json-schema-to-ts';
export const unknownFlagSchema = {
$id: '#/components/schemas/unknownFlagSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'reportedBy'],
description: 'An unknown flag that has been reported by the system',
properties: {
name: {
type: 'string',
description: 'The name of the unknown flag.',
example: 'my-unknown-flag',
},
reportedBy: {
description:
'Details about the application that reported the unknown flag.',
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['appName', 'seenAt'],
properties: {
appName: {
type: 'string',
description:
'The name of the application that reported the unknown flag.',
example: 'my-app',
},
seenAt: {
type: 'string',
format: 'date-time',
description:
'The date and time when the unknown flag was reported.',
example: '2023-10-01T12:00:00Z',
},
},
},
},
},
components: {},
} as const;
export type UnknownFlagSchema = FromSchema<typeof unknownFlagSchema>;

View File

@ -0,0 +1,27 @@
import type { FromSchema } from 'json-schema-to-ts';
import { unknownFlagSchema } from './unknown-flag-schema';
export const unknownFlagsResponseSchema = {
$id: '#/components/schemas/unknownFlagsResponseSchema',
type: 'object',
additionalProperties: false,
required: ['unknownFlags'],
description:
'A list of unknown flags that have been reported by the system',
properties: {
unknownFlags: {
description: 'The list of recently reported unknown flags.',
type: 'array',
items: { $ref: unknownFlagSchema.$id },
},
},
components: {
schemas: {
unknownFlagSchema,
},
},
} as const;
export type UnknownFlagsResponseSchema = FromSchema<
typeof unknownFlagsResponseSchema
>;

View File

@ -32,6 +32,7 @@ import {
outdatedSdksSchema,
type OutdatedSdksSchema,
} from '../../openapi/spec/outdated-sdks-schema';
import UnknownFlagsController from '../../features/metrics/unknown-flags/unknown-flags-controller';
class MetricsController extends Controller {
private logger: Logger;
@ -46,8 +47,12 @@ class MetricsController extends Controller {
config: IUnleashConfig,
{
clientInstanceService,
unknownFlagsService,
openApiService,
}: Pick<IUnleashServices, 'clientInstanceService' | 'openApiService'>,
}: Pick<
IUnleashServices,
'clientInstanceService' | 'unknownFlagsService' | 'openApiService'
>,
) {
super(config);
this.logger = config.getLogger('/admin-api/metrics.ts');
@ -62,6 +67,14 @@ class MetricsController extends Controller {
this.get('/feature-toggles', this.deprecated);
this.get('/feature-toggles/:name', this.deprecated);
this.use(
'/unknown-flags',
new UnknownFlagsController(config, {
unknownFlagsService,
openApiService,
}).router,
);
this.route({
method: 'post',
path: '/applications/:appName',

View File

@ -162,6 +162,7 @@ import { UniqueConnectionService } from '../features/unique-connection/unique-co
import { createFakeFeatureLinkService } from '../features/feature-links/createFeatureLinkService';
import { FeatureLinksReadModel } from '../features/feature-links/feature-links-read-model';
import { FakeFeatureLinksReadModel } from '../features/feature-links/fake-feature-links-read-model';
import { UnknownFlagsService } from '../features/metrics/unknown-flags/unknown-flags-service';
export const createServices = (
stores: IUnleashStores,
@ -193,10 +194,14 @@ export const createServices = (
const lastSeenService = db
? createLastSeenService(db, config)
: createFakeLastSeenService(config);
const unknownFlagsService = new UnknownFlagsService(stores, config);
const clientMetricsServiceV2 = new ClientMetricsServiceV2(
stores,
config,
lastSeenService,
unknownFlagsService,
);
const dependentFeaturesReadModel = db
? new DependentFeaturesReadModel(db)
@ -509,6 +514,7 @@ export const createServices = (
uniqueConnectionService,
featureLifecycleReadModel,
transactionalFeatureLinkService,
unknownFlagsService,
};
};
@ -564,4 +570,5 @@ export {
UserSubscriptionsService,
UniqueConnectionService,
FeatureLifecycleReadModel,
UnknownFlagsService,
};

View File

@ -68,7 +68,8 @@ export type IFlagKey =
| 'cleanupReminder'
| 'removeInactiveApplications'
| 'registerFrontendClient'
| 'featureLinks';
| 'featureLinks'
| 'reportUnknownFlags';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -325,6 +326,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_FEATURE_LINKS,
false,
),
reportUnknownFlags: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -62,6 +62,7 @@ import type { UserSubscriptionsService } from '../features/user-subscriptions/us
import type { UniqueConnectionService } from '../features/unique-connection/unique-connection-service';
import type { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type';
import type FeatureLinkService from '../features/feature-links/feature-link-service';
import type { UnknownFlagsService } from '../internals';
export interface IUnleashServices {
transactionalAccessService: WithTransactional<AccessService>;
@ -135,4 +136,5 @@ export interface IUnleashServices {
uniqueConnectionService: UniqueConnectionService;
featureLifecycleReadModel: IFeatureLifecycleReadModel;
transactionalFeatureLinkService: WithTransactional<FeatureLinkService>;
unknownFlagsService: UnknownFlagsService;
}

View File

@ -60,6 +60,7 @@ import { ReleasePlanTemplateStore } from '../features/release-plans/release-plan
import { ReleasePlanMilestoneStore } from '../features/release-plans/release-plan-milestone-store';
import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/release-plan-milestone-strategy-store';
import type { IFeatureLinkStore } from '../features/feature-links/feature-link-store-type';
import { IUnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -124,6 +125,7 @@ export interface IUnleashStores {
releasePlanMilestoneStore: ReleasePlanMilestoneStore;
releasePlanMilestoneStrategyStore: ReleasePlanMilestoneStrategyStore;
featureLinkStore: IFeatureLinkStore;
unknownFlagsStore: IUnknownFlagsStore;
}
export {
@ -186,4 +188,5 @@ export {
ReleasePlanMilestoneStore,
ReleasePlanMilestoneStrategyStore,
type IFeatureLinkStore,
IUnknownFlagsStore,
};

View File

@ -0,0 +1,23 @@
exports.up = (db, callback) => {
db.runSql(
`
CREATE TABLE IF NOT EXISTS unknown_flags (
name TEXT NOT NULL,
app_name TEXT NOT NULL,
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (name, app_name)
);
`,
callback,
);
};
exports.down = (db, callback) => {
db.runSql(
`
DROP TABLE IF EXISTS unknown_flags;
`,
callback,
);
};

View File

@ -62,6 +62,7 @@ process.nextTick(async () => {
strictSchemaValidation: true,
registerFrontendClient: true,
featureLinks: true,
reportUnknownFlags: true,
},
},
authentication: {

View File

@ -63,6 +63,7 @@ import { FakeUserSubscriptionsReadModel } from '../../lib/features/user-subscrip
import { FakeUniqueConnectionStore } from '../../lib/features/unique-connection/fake-unique-connection-store';
import { UniqueConnectionReadModel } from '../../lib/features/unique-connection/unique-connection-read-model';
import FakeFeatureLinkStore from '../../lib/features/feature-links/fake-feature-link-store';
import { FakeUnknownFlagsStore } from '../../lib/features/metrics/unknown-flags/fake-unknown-flags-store';
const db = {
select: () => ({
@ -72,6 +73,7 @@ const db = {
const createStores: () => IUnleashStores = () => {
const uniqueConnectionStore = new FakeUniqueConnectionStore();
const unknownFlagsStore = new FakeUnknownFlagsStore();
return {
db,
@ -140,6 +142,7 @@ const createStores: () => IUnleashStores = () => {
releasePlanMilestoneStrategyStore:
{} as ReleasePlanMilestoneStrategyStore,
featureLinkStore: new FakeFeatureLinkStore(),
unknownFlagsStore,
};
};