Skip to content

Commit 4fb225b

Browse files
committed
chore: unknown flags
1 parent 8e46bda commit 4fb225b

19 files changed

+469
-7
lines changed

src/lib/db/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { UserUnsubscribeStore } from '../features/user-subscriptions/user-unsubs
6565
import { UserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model';
6666
import { UniqueConnectionStore } from '../features/unique-connection/unique-connection-store';
6767
import { UniqueConnectionReadModel } from '../features/unique-connection/unique-connection-read-model';
68+
import { UnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store';
6869

6970
export const createStores = (
7071
config: IUnleashConfig,
@@ -201,6 +202,7 @@ export const createStores = (
201202
releasePlanMilestoneStore: new ReleasePlanMilestoneStore(db, config),
202203
releasePlanMilestoneStrategyStore:
203204
new ReleasePlanMilestoneStrategyStore(db, config),
205+
unknownFlagsStore: new UnknownFlagsStore(db),
204206
};
205207
};
206208

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

+39-4
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,43 @@ export default class ClientMetricsServiceV2 {
113121
}
114122
}
115123

116-
async filterExistingToggleNames(toggleNames: string[]): Promise<string[]> {
124+
async filterExistingToggleNames(toggleNames: string[]): Promise<{
125+
validatedToggleNames: string[];
126+
unknownToggleNames: string[];
127+
}> {
128+
let unknownToggleNames: string[] = [];
129+
117130
if (this.flagResolver.isEnabled('filterExistingFlagNames')) {
118131
try {
119132
const validNames = await this.cachedFeatureNames();
120133

121134
const existingNames = toggleNames.filter((name) =>
122135
validNames.includes(name),
123136
);
137+
if (this.flagResolver.isEnabled('reportUnknownFlags')) {
138+
unknownToggleNames = toggleNames
139+
.filter((name) => !existingNames.includes(name))
140+
.slice(0, MAX_UNKNOWN_FLAGS);
141+
}
124142
if (existingNames.length !== toggleNames.length) {
125143
this.logger.info(
126144
`Filtered out ${toggleNames.length - existingNames.length} toggles with non-existing names`,
127145
);
128146
}
129-
return this.filterValidToggleNames(existingNames);
147+
148+
const validatedToggleNames =
149+
await this.filterValidToggleNames(existingNames);
150+
151+
return { validatedToggleNames, unknownToggleNames };
130152
} catch (e) {
131153
this.logger.error(e);
132154
}
133155
}
134-
return this.filterValidToggleNames(toggleNames);
156+
157+
const validatedToggleNames =
158+
await this.filterValidToggleNames(toggleNames);
159+
160+
return { validatedToggleNames, unknownToggleNames };
135161
}
136162

137163
async filterValidToggleNames(toggleNames: string[]): Promise<string[]> {
@@ -181,7 +207,7 @@ export default class ClientMetricsServiceV2 {
181207
),
182208
);
183209

184-
const validatedToggleNames =
210+
const { validatedToggleNames, unknownToggleNames } =
185211
await this.filterExistingToggleNames(toggleNames);
186212

187213
this.logger.debug(
@@ -204,6 +230,15 @@ export default class ClientMetricsServiceV2 {
204230
this.config.eventBus.emit(CLIENT_REGISTER, heartbeatEvent);
205231
}
206232

233+
if (unknownToggleNames.length > 0) {
234+
const unknownFlags = unknownToggleNames.map((name) => ({
235+
name,
236+
appName: value.appName,
237+
seenAt: value.bucket.stop,
238+
}));
239+
this.unknownFlagsService.register(unknownFlags);
240+
}
241+
207242
if (validatedToggleNames.length > 0) {
208243
const clientMetrics: IClientMetricsEnv[] = validatedToggleNames.map(
209244
(name) => ({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { IUnknownFlagsStore, UnknownFlag } from './unknown-flags-store';
2+
3+
export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
4+
private unknownFlagRecord: Record<string, UnknownFlag> = {};
5+
6+
async replaceAll(flags: UnknownFlag[]): Promise<void> {
7+
this.unknownFlagRecord = {};
8+
for (const flag of flags) {
9+
this.unknownFlagRecord[flag.name] = flag;
10+
}
11+
}
12+
13+
async getAll(): Promise<UnknownFlag[]> {
14+
return Object.values(this.unknownFlagRecord);
15+
}
16+
17+
async clear(hoursAgo: number): Promise<void> {
18+
const now = new Date();
19+
for (const flag of Object.values(this.unknownFlagRecord)) {
20+
if (
21+
flag.seenAt.getTime() <
22+
now.getTime() - hoursAgo * 60 * 60 * 1000
23+
) {
24+
delete this.unknownFlagRecord[flag.name];
25+
}
26+
}
27+
}
28+
29+
async deleteAll(): Promise<void> {
30+
this.unknownFlagRecord = {};
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
17+
export default class UnknownFlagsController extends Controller {
18+
private unknownFlagsService: UnknownFlagsService;
19+
20+
private flagResolver: IFlagResolver;
21+
22+
private openApiService: OpenApiService;
23+
24+
constructor(
25+
config: IUnleashConfig,
26+
{
27+
unknownFlagsService,
28+
openApiService,
29+
}: Pick<IUnleashServices, 'unknownFlagsService' | 'openApiService'>,
30+
) {
31+
super(config);
32+
this.unknownFlagsService = unknownFlagsService;
33+
this.flagResolver = config.flagResolver;
34+
this.openApiService = openApiService;
35+
36+
this.route({
37+
method: 'get',
38+
path: '',
39+
handler: this.getUnknownFlags,
40+
permission: NONE,
41+
middleware: [
42+
openApiService.validPath({
43+
operationId: 'getUnknownFlags',
44+
tags: ['Unstable'],
45+
summary: 'Get latest reported unknown flag names',
46+
description:
47+
'Returns a list of unknown flag names reported in the last 24 hours, if any. Maximum of 10.',
48+
responses: {
49+
200: createResponseSchema('unknownFlagsResponseSchema'),
50+
},
51+
}),
52+
],
53+
});
54+
}
55+
56+
async getUnknownFlags(
57+
req: IAuthRequest,
58+
res: Response<UnknownFlagsResponseSchema>,
59+
): Promise<void> {
60+
const unknownFlags =
61+
await this.unknownFlagsService.getGroupedUnknownFlags();
62+
63+
this.openApiService.respondWithValidation(
64+
200,
65+
res,
66+
unknownFlagsResponseSchema.$id,
67+
serializeDates({ unknownFlags }),
68+
);
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { Logger } from '../../../logger';
2+
import type { IUnknownFlagsStore, IUnleashConfig } from '../../../types';
3+
import type { IUnleashStores } from '../../../types';
4+
import type { UnknownFlag } from './unknown-flags-store';
5+
6+
export const MAX_UNKNOWN_FLAGS = 10;
7+
8+
export class UnknownFlagsService {
9+
private config: IUnleashConfig;
10+
11+
private logger: Logger;
12+
13+
private unknownFlagsStore: IUnknownFlagsStore;
14+
15+
private unknownFlagsCache: Map<string, UnknownFlag>;
16+
17+
constructor(
18+
{ unknownFlagsStore }: Pick<IUnleashStores, 'unknownFlagsStore'>,
19+
config: IUnleashConfig,
20+
) {
21+
this.unknownFlagsStore = unknownFlagsStore;
22+
this.config = config;
23+
this.logger = config.getLogger(
24+
'/features/metrics/unknown-flags/unknown-flags-service.ts',
25+
);
26+
this.unknownFlagsCache = new Map<string, UnknownFlag>();
27+
}
28+
29+
private getKey(flag: UnknownFlag) {
30+
return `${flag.name}:${flag.appName}`;
31+
}
32+
33+
register(unknownFlags: UnknownFlag[]) {
34+
for (const flag of unknownFlags) {
35+
const key = this.getKey(flag);
36+
37+
if (this.unknownFlagsCache.has(key)) {
38+
this.unknownFlagsCache.set(key, flag);
39+
continue;
40+
}
41+
42+
if (this.unknownFlagsCache.size >= MAX_UNKNOWN_FLAGS) {
43+
const oldestKey = [...this.unknownFlagsCache.entries()].sort(
44+
(a, b) => a[1].seenAt.getTime() - b[1].seenAt.getTime(),
45+
)[0][0];
46+
this.unknownFlagsCache.delete(oldestKey);
47+
}
48+
49+
this.unknownFlagsCache.set(key, flag);
50+
}
51+
}
52+
53+
async flush(): Promise<void> {
54+
if (this.unknownFlagsCache.size === 0) return;
55+
56+
const existing = await this.unknownFlagsStore.getAll();
57+
const cached = Array.from(this.unknownFlagsCache.values());
58+
59+
const merged = [...existing, ...cached];
60+
const mergedMap = new Map<string, UnknownFlag>();
61+
62+
for (const flag of merged) {
63+
const key = this.getKey(flag);
64+
const existing = mergedMap.get(key);
65+
if (!existing || flag.seenAt > existing.seenAt) {
66+
mergedMap.set(key, flag);
67+
}
68+
}
69+
70+
const latest = Array.from(mergedMap.values())
71+
.sort((a, b) => b.seenAt.getTime() - a.seenAt.getTime())
72+
.slice(0, MAX_UNKNOWN_FLAGS);
73+
74+
await this.unknownFlagsStore.replaceAll(latest);
75+
this.unknownFlagsCache.clear();
76+
}
77+
78+
async getGroupedUnknownFlags(): Promise<
79+
{ name: string; reportedBy: { appName: string; seenAt: Date }[] }[]
80+
> {
81+
const unknownFlags = await this.unknownFlagsStore.getAll();
82+
83+
const grouped = new Map<string, { appName: string; seenAt: Date }[]>();
84+
85+
for (const { name, appName, seenAt } of unknownFlags) {
86+
if (!grouped.has(name)) {
87+
grouped.set(name, []);
88+
}
89+
grouped.get(name)!.push({ appName, seenAt });
90+
}
91+
92+
return Array.from(grouped.entries()).map(([name, reportedBy]) => ({
93+
name,
94+
reportedBy,
95+
}));
96+
}
97+
98+
async clear(hoursAgo: number) {
99+
return this.unknownFlagsStore.clear(hoursAgo);
100+
}
101+
}

0 commit comments

Comments
 (0)