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:
parent
2b73b17579
commit
eb238f502a
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) => ({
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
107
src/lib/features/metrics/unknown-flags/unknown-flags-service.ts
Normal file
107
src/lib/features/metrics/unknown-flags/unknown-flags-service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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',
|
||||
);
|
||||
};
|
||||
|
@ -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) {}
|
||||
},
|
||||
};
|
||||
|
@ -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';
|
||||
|
44
src/lib/openapi/spec/unknown-flag-schema.ts
Normal file
44
src/lib/openapi/spec/unknown-flag-schema.ts
Normal 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>;
|
27
src/lib/openapi/spec/unknown-flags-response-schema.ts
Normal file
27
src/lib/openapi/spec/unknown-flags-response-schema.ts
Normal 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
|
||||
>;
|
@ -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',
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
23
src/migrations/20250424185110-unknown-flags.js
Normal file
23
src/migrations/20250424185110-unknown-flags.js
Normal 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,
|
||||
);
|
||||
};
|
@ -62,6 +62,7 @@ process.nextTick(async () => {
|
||||
strictSchemaValidation: true,
|
||||
registerFrontendClient: true,
|
||||
featureLinks: true,
|
||||
reportUnknownFlags: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
3
src/test/fixtures/store.ts
vendored
3
src/test/fixtures/store.ts
vendored
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user