Skip to content

Commit eb238f5

Browse files
authored
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" />
1 parent 2b73b17 commit eb238f5

20 files changed

+517
-13
lines changed

src/lib/db/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { UserSubscriptionsReadModel } from '../features/user-subscriptions/user-
6666
import { UniqueConnectionStore } from '../features/unique-connection/unique-connection-store';
6767
import { UniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model';
6868
import { FeatureLinkStore } from '../features/feature-links/feature-link-store';
69+
import { UnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store';
6970

7071
export const createStores = (
7172
config: IUnleashConfig,
@@ -203,6 +204,7 @@ export const createStores = (
203204
releasePlanMilestoneStrategyStore:
204205
new ReleasePlanMilestoneStrategyStore(db, config),
205206
featureLinkStore: new FeatureLinkStore(db, config),
207+
unknownFlagsStore: new UnknownFlagsStore(db),
206208
};
207209
};
208210

src/lib/features/metrics/client-metrics/metrics-service-v2.test.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from '../../../../lib/types';
1212
import { endOfDay, startOfHour, subDays, subHours } from 'date-fns';
1313
import type { IClientMetricsEnv } from './client-metrics-store-v2-type';
14+
import { UnknownFlagsService } from '../unknown-flags/unknown-flags-service';
1415

1516
function initClientMetrics(flagEnabled = true) {
1617
const stores = createStores();
@@ -35,8 +36,19 @@ function initClientMetrics(flagEnabled = true) {
3536
config,
3637
);
3738
lastSeenService.updateLastSeen = jest.fn();
39+
const unknownFlagsService = new UnknownFlagsService(
40+
{
41+
unknownFlagsStore: stores.unknownFlagsStore,
42+
},
43+
config,
44+
);
3845

39-
const service = new ClientMetricsServiceV2(stores, config, lastSeenService);
46+
const service = new ClientMetricsServiceV2(
47+
stores,
48+
config,
49+
lastSeenService,
50+
unknownFlagsService,
51+
);
4052
return { clientMetricsService: service, eventBus, lastSeenService };
4153
}
4254

@@ -161,10 +173,12 @@ test('get daily client metrics for a toggle', async () => {
161173
getLogger() {},
162174
} as unknown as IUnleashConfig;
163175
const lastSeenService = {} as LastSeenService;
176+
const unknownFlagsService = {} as UnknownFlagsService;
164177
const service = new ClientMetricsServiceV2(
165178
{ clientMetricsStoreV2 },
166179
config,
167180
lastSeenService,
181+
unknownFlagsService,
168182
);
169183

170184
const metrics = await service.getClientMetricsForToggle('feature', 3 * 24);
@@ -217,10 +231,12 @@ test('get hourly client metrics for a toggle', async () => {
217231
getLogger() {},
218232
} as unknown as IUnleashConfig;
219233
const lastSeenService = {} as LastSeenService;
234+
const unknownFlagsService = {} as UnknownFlagsService;
220235
const service = new ClientMetricsServiceV2(
221236
{ clientMetricsStoreV2 },
222237
config,
223238
lastSeenService,
239+
unknownFlagsService,
224240
);
225241

226242
const metrics = await service.getClientMetricsForToggle('feature', 2);
@@ -287,10 +303,12 @@ const setupMetricsService = ({
287303
},
288304
} as unknown as IUnleashConfig;
289305
const lastSeenService = {} as LastSeenService;
306+
const unknownFlagsService = {} as UnknownFlagsService;
290307
const service = new ClientMetricsServiceV2(
291308
{ clientMetricsStoreV2 },
292309
config,
293310
lastSeenService,
311+
unknownFlagsService,
294312
);
295313
return {
296314
service,

src/lib/features/metrics/client-metrics/metrics-service-v2.ts

+57-10
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
import type { ClientMetricsSchema } from '../../../../lib/openapi';
2727
import { nameSchema } from '../../../schema/feature-schema';
2828
import memoizee from 'memoizee';
29+
import {
30+
MAX_UNKNOWN_FLAGS,
31+
type UnknownFlagsService,
32+
} from '../unknown-flags/unknown-flags-service';
2933

3034
export default class ClientMetricsServiceV2 {
3135
private config: IUnleashConfig;
@@ -36,6 +40,8 @@ export default class ClientMetricsServiceV2 {
3640

3741
private lastSeenService: LastSeenService;
3842

43+
private unknownFlagsService: UnknownFlagsService;
44+
3945
private flagResolver: Pick<IFlagResolver, 'isEnabled' | 'getVariant'>;
4046

4147
private logger: Logger;
@@ -46,9 +52,11 @@ export default class ClientMetricsServiceV2 {
4652
{ clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
4753
config: IUnleashConfig,
4854
lastSeenService: LastSeenService,
55+
unknownFlagsService: UnknownFlagsService,
4956
) {
5057
this.clientMetricsStoreV2 = clientMetricsStoreV2;
5158
this.lastSeenService = lastSeenService;
59+
this.unknownFlagsService = unknownFlagsService;
5260
this.config = config;
5361
this.logger = config.getLogger(
5462
'/services/client-metrics/client-metrics-service-v2.ts',
@@ -113,25 +121,55 @@ export default class ClientMetricsServiceV2 {
113121
}
114122
}
115123

116-
async filterExistingToggleNames(toggleNames: string[]): Promise<string[]> {
117-
if (this.flagResolver.isEnabled('filterExistingFlagNames')) {
124+
async filterExistingToggleNames(toggleNames: string[]): Promise<{
125+
validatedToggleNames: string[];
126+
unknownToggleNames: string[];
127+
}> {
128+
let toggleNamesToValidate: string[] = toggleNames;
129+
let unknownToggleNames: string[] = [];
130+
131+
const shouldFilter = this.flagResolver.isEnabled(
132+
'filterExistingFlagNames',
133+
);
134+
const shouldReport = this.flagResolver.isEnabled('reportUnknownFlags');
135+
136+
if (shouldFilter || shouldReport) {
118137
try {
119-
const validNames = await this.cachedFeatureNames();
138+
const existingFlags = await this.cachedFeatureNames();
120139

121140
const existingNames = toggleNames.filter((name) =>
122-
validNames.includes(name),
141+
existingFlags.includes(name),
142+
);
143+
const nonExistingNames = toggleNames.filter(
144+
(name) => !existingFlags.includes(name),
123145
);
124-
if (existingNames.length !== toggleNames.length) {
125-
this.logger.info(
126-
`Filtered out ${toggleNames.length - existingNames.length} toggles with non-existing names`,
146+
147+
if (shouldFilter) {
148+
toggleNamesToValidate = existingNames;
149+
150+
if (existingNames.length !== toggleNames.length) {
151+
this.logger.info(
152+
`Filtered out ${toggleNames.length - existingNames.length} toggles with non-existing names`,
153+
);
154+
}
155+
}
156+
157+
if (shouldReport) {
158+
unknownToggleNames = nonExistingNames.slice(
159+
0,
160+
MAX_UNKNOWN_FLAGS,
127161
);
128162
}
129-
return this.filterValidToggleNames(existingNames);
130163
} catch (e) {
131164
this.logger.error(e);
132165
}
133166
}
134-
return this.filterValidToggleNames(toggleNames);
167+
168+
const validatedToggleNames = await this.filterValidToggleNames(
169+
toggleNamesToValidate,
170+
);
171+
172+
return { validatedToggleNames, unknownToggleNames };
135173
}
136174

137175
async filterValidToggleNames(toggleNames: string[]): Promise<string[]> {
@@ -181,7 +219,7 @@ export default class ClientMetricsServiceV2 {
181219
),
182220
);
183221

184-
const validatedToggleNames =
222+
const { validatedToggleNames, unknownToggleNames } =
185223
await this.filterExistingToggleNames(toggleNames);
186224

187225
this.logger.debug(
@@ -204,6 +242,15 @@ export default class ClientMetricsServiceV2 {
204242
this.config.eventBus.emit(CLIENT_REGISTER, heartbeatEvent);
205243
}
206244

245+
if (unknownToggleNames.length > 0) {
246+
const unknownFlags = unknownToggleNames.map((name) => ({
247+
name,
248+
appName: value.appName,
249+
seenAt: value.bucket.stop,
250+
}));
251+
this.unknownFlagsService.register(unknownFlags);
252+
}
253+
207254
if (validatedToggleNames.length > 0) {
208255
const clientMetrics: IClientMetricsEnv[] = validatedToggleNames.map(
209256
(name) => ({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { IUnknownFlagsStore, UnknownFlag } from './unknown-flags-store';
2+
3+
export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
4+
private unknownFlagMap = new Map<string, UnknownFlag>();
5+
6+
private getKey(flag: UnknownFlag): string {
7+
return `${flag.name}:${flag.appName}`;
8+
}
9+
10+
async replaceAll(flags: UnknownFlag[]): Promise<void> {
11+
this.unknownFlagMap.clear();
12+
for (const flag of flags) {
13+
this.unknownFlagMap.set(this.getKey(flag), flag);
14+
}
15+
}
16+
17+
async getAll(): Promise<UnknownFlag[]> {
18+
return Array.from(this.unknownFlagMap.values());
19+
}
20+
21+
async clear(hoursAgo: number): Promise<void> {
22+
const cutoff = Date.now() - hoursAgo * 60 * 60 * 1000;
23+
for (const [key, flag] of this.unknownFlagMap.entries()) {
24+
if (flag.seenAt.getTime() < cutoff) {
25+
this.unknownFlagMap.delete(key);
26+
}
27+
}
28+
}
29+
30+
async deleteAll(): Promise<void> {
31+
this.unknownFlagMap.clear();
32+
}
33+
34+
async count(): Promise<number> {
35+
return this.unknownFlagMap.size;
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Response } from 'express';
2+
import {
3+
unknownFlagsResponseSchema,
4+
type UnknownFlagsResponseSchema,
5+
} from '../../../openapi';
6+
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
7+
import Controller from '../../../routes/controller';
8+
import type { IAuthRequest } from '../../../routes/unleash-types';
9+
import type { OpenApiService } from '../../../services/openapi-service';
10+
import type { IFlagResolver } from '../../../types/experimental';
11+
import type { IUnleashConfig } from '../../../types/option';
12+
import { NONE } from '../../../types/permissions';
13+
import { serializeDates } from '../../../types/serialize-dates';
14+
import type { IUnleashServices } from '../../../types/services';
15+
import type { UnknownFlagsService } from './unknown-flags-service';
16+
import { NotFoundError } from '../../../error';
17+
18+
export default class UnknownFlagsController extends Controller {
19+
private unknownFlagsService: UnknownFlagsService;
20+
21+
private flagResolver: IFlagResolver;
22+
23+
private openApiService: OpenApiService;
24+
25+
constructor(
26+
config: IUnleashConfig,
27+
{
28+
unknownFlagsService,
29+
openApiService,
30+
}: Pick<IUnleashServices, 'unknownFlagsService' | 'openApiService'>,
31+
) {
32+
super(config);
33+
this.unknownFlagsService = unknownFlagsService;
34+
this.flagResolver = config.flagResolver;
35+
this.openApiService = openApiService;
36+
37+
this.route({
38+
method: 'get',
39+
path: '',
40+
handler: this.getUnknownFlags,
41+
permission: NONE,
42+
middleware: [
43+
openApiService.validPath({
44+
operationId: 'getUnknownFlags',
45+
tags: ['Unstable'],
46+
summary: 'Get latest reported unknown flag names',
47+
description:
48+
'Returns a list of unknown flag names reported in the last 24 hours, if any. Maximum of 10.',
49+
responses: {
50+
200: createResponseSchema('unknownFlagsResponseSchema'),
51+
},
52+
}),
53+
],
54+
});
55+
}
56+
57+
async getUnknownFlags(
58+
_: IAuthRequest,
59+
res: Response<UnknownFlagsResponseSchema>,
60+
): Promise<void> {
61+
if (!this.flagResolver.isEnabled('reportUnknownFlags')) {
62+
throw new NotFoundError();
63+
}
64+
const unknownFlags =
65+
await this.unknownFlagsService.getGroupedUnknownFlags();
66+
67+
this.openApiService.respondWithValidation(
68+
200,
69+
res,
70+
unknownFlagsResponseSchema.$id,
71+
serializeDates({ unknownFlags }),
72+
);
73+
}
74+
}

0 commit comments

Comments
 (0)