diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 2e05c80838..3b2972b874 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), }; }; diff --git a/src/lib/features/metrics/client-metrics/metrics-service-v2.test.ts b/src/lib/features/metrics/client-metrics/metrics-service-v2.test.ts index ac5102eb62..e537d1ee1b 100644 --- a/src/lib/features/metrics/client-metrics/metrics-service-v2.test.ts +++ b/src/lib/features/metrics/client-metrics/metrics-service-v2.test.ts @@ -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, diff --git a/src/lib/features/metrics/client-metrics/metrics-service-v2.ts b/src/lib/features/metrics/client-metrics/metrics-service-v2.ts index 1e29c15436..ffa241cffd 100644 --- a/src/lib/features/metrics/client-metrics/metrics-service-v2.ts +++ b/src/lib/features/metrics/client-metrics/metrics-service-v2.ts @@ -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; private logger: Logger; @@ -46,9 +52,11 @@ export default class ClientMetricsServiceV2 { { clientMetricsStoreV2 }: Pick, 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 { - 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 { @@ -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) => ({ diff --git a/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts b/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts new file mode 100644 index 0000000000..8bc9ed07aa --- /dev/null +++ b/src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts @@ -0,0 +1,37 @@ +import type { IUnknownFlagsStore, UnknownFlag } from './unknown-flags-store'; + +export class FakeUnknownFlagsStore implements IUnknownFlagsStore { + private unknownFlagMap = new Map(); + + private getKey(flag: UnknownFlag): string { + return `${flag.name}:${flag.appName}`; + } + + async replaceAll(flags: UnknownFlag[]): Promise { + this.unknownFlagMap.clear(); + for (const flag of flags) { + this.unknownFlagMap.set(this.getKey(flag), flag); + } + } + + async getAll(): Promise { + return Array.from(this.unknownFlagMap.values()); + } + + async clear(hoursAgo: number): Promise { + 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 { + this.unknownFlagMap.clear(); + } + + async count(): Promise { + return this.unknownFlagMap.size; + } +} diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts new file mode 100644 index 0000000000..3d285a87a3 --- /dev/null +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts @@ -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, + ) { + 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, + ): Promise { + if (!this.flagResolver.isEnabled('reportUnknownFlags')) { + throw new NotFoundError(); + } + const unknownFlags = + await this.unknownFlagsService.getGroupedUnknownFlags(); + + this.openApiService.respondWithValidation( + 200, + res, + unknownFlagsResponseSchema.$id, + serializeDates({ unknownFlags }), + ); + } +} diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts new file mode 100644 index 0000000000..1482ec417d --- /dev/null +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-service.ts @@ -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; + + constructor( + { unknownFlagsStore }: Pick, + 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(); + } + + 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 { + 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(); + + 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(); + + 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); + } +} diff --git a/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts b/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts new file mode 100644 index 0000000000..7659dbd2af --- /dev/null +++ b/src/lib/features/metrics/unknown-flags/unknown-flags-store.ts @@ -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; + getAll(): Promise; + clear(hoursAgo: number): Promise; + deleteAll(): Promise; + count(): Promise; +} + +export class UnknownFlagsStore implements IUnknownFlagsStore { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async replaceAll(flags: UnknownFlag[]): Promise { + 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 { + 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 { + return this.db(TABLE) + .whereRaw(`seen_at <= NOW() - INTERVAL '${hoursAgo} hours'`) + .del(); + } + + async deleteAll(): Promise { + await this.db(TABLE).delete(); + } + + async count(): Promise { + const row = await this.db(TABLE).count('* as count').first(); + return Number(row?.count ?? 0); + } +} diff --git a/src/lib/features/scheduler/schedule-services.ts b/src/lib/features/scheduler/schedule-services.ts index 6e0b2c194d..a1f1510916 100644 --- a/src/lib/features/scheduler/schedule-services.ts +++ b/src/lib/features/scheduler/schedule-services.ts @@ -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', + ); }; diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index fd7599cbb1..cc7e04433f 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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) {} }, }; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 0ec79acafd..578fd1f1c3 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/openapi/spec/unknown-flag-schema.ts b/src/lib/openapi/spec/unknown-flag-schema.ts new file mode 100644 index 0000000000..dc40089d64 --- /dev/null +++ b/src/lib/openapi/spec/unknown-flag-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/unknown-flags-response-schema.ts b/src/lib/openapi/spec/unknown-flags-response-schema.ts new file mode 100644 index 0000000000..f4d1a89be5 --- /dev/null +++ b/src/lib/openapi/spec/unknown-flags-response-schema.ts @@ -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 +>; diff --git a/src/lib/routes/admin-api/metrics.ts b/src/lib/routes/admin-api/metrics.ts index a6f0e51956..4e4c942391 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -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, + }: 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', diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 053f7c505a..063e77bd59 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -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, }; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index dbcf64a018..e66fe448b1 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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 = { diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 559ada92b7..9009f1c2ec 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -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; @@ -135,4 +136,5 @@ export interface IUnleashServices { uniqueConnectionService: UniqueConnectionService; featureLifecycleReadModel: IFeatureLifecycleReadModel; transactionalFeatureLinkService: WithTransactional; + unknownFlagsService: UnknownFlagsService; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index c31ece2cdb..6c0b6b823f 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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, }; diff --git a/src/migrations/20250424185110-unknown-flags.js b/src/migrations/20250424185110-unknown-flags.js new file mode 100644 index 0000000000..1681e05f86 --- /dev/null +++ b/src/migrations/20250424185110-unknown-flags.js @@ -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, + ); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 7bb501a5d9..59d3a4b0c5 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -62,6 +62,7 @@ process.nextTick(async () => { strictSchemaValidation: true, registerFrontendClient: true, featureLinks: true, + reportUnknownFlags: true, }, }, authentication: { diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index c78521b248..4449f37eeb 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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, }; };