From eb238f502a5b75a27f36ebf2c65332809c4e8ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 7 May 2025 11:48:36 +0100 Subject: [PATCH] chore: unknown flags (#9837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. image --- src/lib/db/index.ts | 2 + .../client-metrics/metrics-service-v2.test.ts | 20 +++- .../client-metrics/metrics-service-v2.ts | 67 +++++++++-- .../unknown-flags/fake-unknown-flags-store.ts | 37 ++++++ .../unknown-flags/unknown-flags-controller.ts | 74 ++++++++++++ .../unknown-flags/unknown-flags-service.ts | 107 ++++++++++++++++++ .../unknown-flags/unknown-flags-store.ts | 67 +++++++++++ .../features/scheduler/schedule-services.ts | 13 +++ src/lib/metrics.ts | 9 ++ src/lib/openapi/spec/index.ts | 2 + src/lib/openapi/spec/unknown-flag-schema.ts | 44 +++++++ .../spec/unknown-flags-response-schema.ts | 27 +++++ src/lib/routes/admin-api/metrics.ts | 15 ++- src/lib/services/index.ts | 7 ++ src/lib/types/experimental.ts | 7 +- src/lib/types/services.ts | 2 + src/lib/types/stores.ts | 3 + .../20250424185110-unknown-flags.js | 23 ++++ src/server-dev.ts | 1 + src/test/fixtures/store.ts | 3 + 20 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 src/lib/features/metrics/unknown-flags/fake-unknown-flags-store.ts create mode 100644 src/lib/features/metrics/unknown-flags/unknown-flags-controller.ts create mode 100644 src/lib/features/metrics/unknown-flags/unknown-flags-service.ts create mode 100644 src/lib/features/metrics/unknown-flags/unknown-flags-store.ts create mode 100644 src/lib/openapi/spec/unknown-flag-schema.ts create mode 100644 src/lib/openapi/spec/unknown-flags-response-schema.ts create mode 100644 src/migrations/20250424185110-unknown-flags.js 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, }; };