1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00

chore: one unknown flag per row

This commit is contained in:
Nuno Góis 2025-09-01 16:36:10 +01:00
parent 829c2c5bc3
commit a2a7d5b4ff
No known key found for this signature in database
GPG Key ID: 71ECC689F1091765
10 changed files with 427 additions and 126 deletions

View File

@ -11,7 +11,7 @@ interface IUnknownFlagsSeenInUnleashCellProps extends ITimeAgoCellProps {
unknownFlag: UnknownFlag;
}
export const UnknownFlagsSeenInUnleashCell = ({
export const UnknownFlagsLastEventCell = ({
unknownFlag,
...props
}: IUnknownFlagsSeenInUnleashCellProps) => {

View File

@ -0,0 +1,128 @@
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import type { UnknownFlag } from './hooks/useUnknownFlags.js';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { formatDateYMDHMS } from 'utils/formatDate.js';
import { useLocationSettings } from 'hooks/useLocationSettings.js';
import { styled } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter.js';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext.js';
const REPORT_APP_LIMIT = 20;
const REPORT_ENV_LIMIT = 10;
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
const StyledReport = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > ul': {
padding: theme.spacing(0, 3),
margin: 0,
},
}));
interface IUnknownFlagsLastReportedCellProps {
row: { original: UnknownFlag };
}
const UnknownFlagsLastReportedCellTooltip = ({
unknownFlag,
searchQuery,
}: { unknownFlag: UnknownFlag; searchQuery: string }) => {
const { locationSettings } = useLocationSettings();
const lastReported = formatDateYMDHMS(
unknownFlag.lastSeenAt,
locationSettings.locale,
);
return (
<StyledTooltip>
Last reported: {lastReported}
{unknownFlag.reports
.slice(0, REPORT_APP_LIMIT)
.map(({ appName, environments }) => (
<StyledReport key={appName}>
<b>
<Highlighter search={searchQuery}>
{appName}
</Highlighter>
</b>
<ul>
{environments
.slice(0, REPORT_ENV_LIMIT)
.map(({ environment, seenAt }) => (
<li key={environment}>
<Highlighter search={searchQuery}>
{environment}
</Highlighter>
:{' '}
{formatDateYMDHMS(
seenAt,
locationSettings.locale,
)}
</li>
))}
{environments.length > REPORT_ENV_LIMIT && (
<li>
and {environments.length - REPORT_ENV_LIMIT}{' '}
more
</li>
)}
</ul>
</StyledReport>
))}
{unknownFlag.reports.length > REPORT_APP_LIMIT && (
<span>
and {unknownFlag.reports.length - REPORT_APP_LIMIT} more
</span>
)}
</StyledTooltip>
);
};
export const UnknownFlagsLastReportedCell = ({
row,
}: IUnknownFlagsLastReportedCellProps) => {
const { original: unknownFlag } = row;
const { searchQuery } = useSearchHighlightContext();
const searchableAppNames = Array.from(
new Set(
unknownFlag.reports.map((report) => report.appName.toLowerCase()),
),
).join('\n');
const searchableEnvironments = Array.from(
new Set(
unknownFlag.reports.flatMap((report) =>
report.environments.map((env) => env.environment.toLowerCase()),
),
),
).join('\n');
return (
<TextCell>
<TooltipLink
tooltip={
<UnknownFlagsLastReportedCellTooltip
unknownFlag={unknownFlag}
searchQuery={searchQuery}
/>
}
highlighted={
searchQuery.length > 0 &&
(searchableAppNames.includes(searchQuery.toLowerCase()) ||
searchableEnvironments.includes(
searchQuery.toLowerCase(),
))
}
>
<TimeAgo date={unknownFlag.lastSeenAt} />
</TooltipLink>
</TextCell>
);
};

View File

@ -11,14 +11,14 @@ import { Search } from 'component/common/Search/Search';
import { useSearch } from 'hooks/useSearch';
import { type UnknownFlag, useUnknownFlags } from './hooks/useUnknownFlags.js';
import theme from 'themes/theme.js';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell.js';
import { formatDateYMDHMS } from 'utils/formatDate.js';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell.js';
import { useUiFlag } from 'hooks/useUiFlag.js';
import NotFound from 'component/common/NotFound/NotFound.js';
import { UnknownFlagsSeenInUnleashCell } from './UnknownFlagsSeenInUnleashCell.js';
import { UnknownFlagsLastEventCell } from './UnknownFlagsLastEventCell.js';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon.js';
import { UnknownFlagsActionsCell } from './UnknownFlagsActionsCell.js';
import { UnknownFlagsLastReportedCell } from './UnknownFlagsLastReportedCell.js';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(3),
@ -51,36 +51,19 @@ export const UnknownFlagsTable = () => {
minWidth: 100,
searchable: true,
},
{
Header: 'Application',
accessor: 'appName',
searchable: true,
minWidth: 100,
},
{
Header: 'Environment',
accessor: 'environment',
searchable: true,
},
{
Header: (
<StyledHeader>
Reported
Last reported
<HelpIcon
tooltip={`Feature flags are reported when your SDK evaluates them and they don't exist in Unleash`}
size='16px'
/>
</StyledHeader>
),
accessor: 'seenAt',
Cell: ({ value }) => (
<TimeAgoCell
value={value}
title={(date) => `Reported: ${date}`}
dateFormat={formatDateYMDHMS}
/>
),
width: 150,
accessor: 'lastSeenAt',
Cell: UnknownFlagsLastReportedCell,
width: 170,
},
{
Header: (
@ -98,7 +81,7 @@ export const UnknownFlagsTable = () => {
}: {
row: { original: UnknownFlag };
}) => (
<UnknownFlagsSeenInUnleashCell
<UnknownFlagsLastEventCell
unknownFlag={unknownFlag}
dateFormat={formatDateYMDHMS}
/>
@ -116,12 +99,34 @@ export const UnknownFlagsTable = () => {
width: 100,
disableSortBy: true,
},
// Always hidden -- for search
{
accessor: (row: UnknownFlag) =>
row.reports.map(({ appName }) => appName).join('\n'),
id: 'appNames',
searchable: true,
},
{
accessor: (row: UnknownFlag) =>
Array.from(
new Set(
row.reports.flatMap(({ environments }) =>
environments.map(
({ environment }) => environment,
),
),
),
).join('\n'),
id: 'environments',
searchable: true,
},
],
[],
);
const [initialState] = useState({
sortBy: [{ id: 'name', desc: false }],
hiddenColumns: ['appNames', 'environments'],
});
const { data, getSearchText } = useSearch(
@ -155,7 +160,7 @@ export const UnknownFlagsTable = () => {
isLoading={loading}
header={
<PageHeader
title={`Unknown flag report (${rows.length})`}
title={`Unknown flags (${rows.length})`}
actions={
<>
<ConditionallyRender
@ -187,7 +192,7 @@ export const UnknownFlagsTable = () => {
>
<StyledAlert severity='info'>
<StyledAlertContent>
<p>
<div>
<b>
Clean up unknown flags to keep your code and
configuration in sync
@ -195,9 +200,9 @@ export const UnknownFlagsTable = () => {
<br />
Unknown flags are feature flags that your SDKs tried to
evaluate but which Unleash doesn't recognize.
</p>
</div>
<p>
<div>
<b>Unknown flags can include:</b>
<ul>
<li>
@ -209,14 +214,7 @@ export const UnknownFlagsTable = () => {
unexpected names, unsupported by Unleash.
</li>
</ul>
</p>
<p>
<b>Why do I see the same flag name multiple times?</b>
<br />
The same flag name will appear multiple times if it's
been seen in different applications or environments.
</p>
</div>
</StyledAlertContent>
</StyledAlert>

View File

@ -5,12 +5,21 @@ import { useConditionalSWR } from 'hooks/api/getters/useConditionalSWR/useCondit
import handleErrorResponses from 'hooks/api/getters/httpErrorResponseHandler';
import type { SWRConfiguration } from 'swr';
type UnknownFlagEnvReport = {
environment: string;
seenAt: Date;
};
type UnknownFlagAppReport = {
appName: string;
environments: UnknownFlagEnvReport[];
};
export type UnknownFlag = {
name: string;
appName: string;
seenAt: Date;
environment: string;
lastEventAt: Date;
lastSeenAt: Date;
lastEventAt?: Date;
reports: UnknownFlagAppReport[];
};
type UnknownFlagsResponse = {

View File

@ -32,7 +32,7 @@ import {
MetricsTranslator,
} from '../impact/metrics-translator.js';
import { impactRegister } from '../impact/impact-register.js';
import type { UnknownFlag } from '../unknown-flags/unknown-flags-store.js';
import type { UnknownFlagReport } from '../unknown-flags/unknown-flags-store.js';
export default class ClientMetricsServiceV2 {
private config: IUnleashConfig;
@ -209,14 +209,14 @@ export default class ClientMetricsServiceV2 {
`Got ${toggleNames.length} metrics (${invalidCount > 0 ? `${invalidCount} invalid` : 'all valid'}).`,
);
const unknownFlags: UnknownFlag[] = [];
const unknownFlags: UnknownFlagReport[] = [];
for (const [featureName, group] of metricsByToggle) {
if (unknownSet.has(featureName)) {
for (const m of group) {
unknownFlags.push({
name: featureName,
appName: m.appName,
seenAt: m.timestamp,
lastSeenAt: m.timestamp,
environment: m.environment,
});
}

View File

@ -1,25 +1,67 @@
import type {
IUnknownFlagsStore,
UnknownFlag,
UnknownFlagReport,
QueryParams,
} from './unknown-flags-store.js';
export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
private unknownFlagMap = new Map<string, UnknownFlag>();
private unknownFlagMap = new Map<string, UnknownFlagReport>();
private getKey(flag: UnknownFlag): string {
private getKey(flag: UnknownFlagReport): string {
return `${flag.name}:${flag.appName}:${flag.environment}`;
}
async insert(flags: UnknownFlag[]): Promise<void> {
async insert(flags: UnknownFlagReport[]): Promise<void> {
this.unknownFlagMap.clear();
for (const flag of flags) {
this.unknownFlagMap.set(this.getKey(flag), flag);
}
}
private groupFlags(flags: UnknownFlagReport[]): UnknownFlag[] {
const byName = new Map<string, Map<string, Map<string, Date>>>();
for (const f of flags) {
const apps =
byName.get(f.name) ?? new Map<string, Map<string, Date>>();
const envs = apps.get(f.appName) ?? new Map<string, Date>();
const prev = envs.get(f.environment);
if (!prev || f.lastSeenAt > prev)
envs.set(f.environment, f.lastSeenAt);
apps.set(f.appName, envs);
byName.set(f.name, apps);
}
const out: UnknownFlag[] = [];
for (const [name, appsMap] of byName) {
let lastSeenAt: Date | null = null;
const reports = Array.from(appsMap.entries()).map(
([appName, envMap]) => {
const environments = Array.from(envMap.entries()).map(
([environment, seenAt]) => {
if (!lastSeenAt || seenAt > lastSeenAt)
lastSeenAt = seenAt;
return { environment, seenAt };
},
);
return { appName, environments };
},
);
out.push({
name,
lastSeenAt: lastSeenAt ?? new Date(0),
reports,
});
}
return out;
}
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
const flags = Array.from(this.unknownFlagMap.values());
const flat = Array.from(this.unknownFlagMap.values());
const flags = this.groupFlags(flat);
if (orderBy) {
flags.sort((a, b) => {
for (const { column, order } of orderBy) {
@ -36,7 +78,7 @@ export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
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) {
if (flag.lastSeenAt.getTime() < cutoff) {
this.unknownFlagMap.delete(key);
}
}

View File

@ -5,7 +5,11 @@ import type {
IUnleashConfig,
} from '../../../types/index.js';
import type { IUnleashStores } from '../../../types/index.js';
import type { QueryParams, UnknownFlag } from './unknown-flags-store.js';
import type {
QueryParams,
UnknownFlag,
UnknownFlagReport,
} from './unknown-flags-store.js';
export class UnknownFlagsService {
private logger: Logger;
@ -14,7 +18,7 @@ export class UnknownFlagsService {
private unknownFlagsStore: IUnknownFlagsStore;
private unknownFlagsCache: Map<string, UnknownFlag>;
private unknownFlagsCache: Map<string, UnknownFlagReport>;
constructor(
{ unknownFlagsStore }: Pick<IUnleashStores, 'unknownFlagsStore'>,
@ -25,14 +29,14 @@ export class UnknownFlagsService {
this.logger = config.getLogger(
'/features/metrics/unknown-flags/unknown-flags-service.ts',
);
this.unknownFlagsCache = new Map<string, UnknownFlag>();
this.unknownFlagsCache = new Map<string, UnknownFlagReport>();
}
private getKey(flag: UnknownFlag) {
private getKey(flag: UnknownFlagReport) {
return `${flag.name}:${flag.appName}:${flag.environment}`;
}
register(unknownFlags: UnknownFlag[]) {
register(unknownFlags: UnknownFlagReport[]) {
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return;
for (const flag of unknownFlags) {
const key = this.getKey(flag);

View File

@ -5,12 +5,28 @@ const TABLE = 'unknown_flags';
const TABLE_EVENTS = 'events';
const MAX_INSERT_BATCH_SIZE = 100;
type UnknownFlagEnvReport = {
environment: string;
seenAt: Date;
};
type UnknownFlagAppReport = {
appName: string;
environments: UnknownFlagEnvReport[];
};
export type UnknownFlag = {
name: string;
appName: string;
seenAt: Date;
environment: string;
lastSeenAt: Date;
lastEventAt?: Date;
reports: UnknownFlagAppReport[];
};
export type UnknownFlagReport = {
name: string;
appName: string;
lastSeenAt: Date;
environment: string;
};
export type QueryParams = {
@ -22,7 +38,7 @@ export type QueryParams = {
};
export interface IUnknownFlagsStore {
insert(flags: UnknownFlag[]): Promise<void>;
insert(flags: UnknownFlagReport[]): Promise<void>;
getAll(params?: QueryParams): Promise<UnknownFlag[]>;
clear(hoursAgo: number): Promise<void>;
deleteAll(): Promise<void>;
@ -39,15 +55,17 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
this.logger = getLogger('unknown-flags-store.ts');
}
async insert(flags: UnknownFlag[]): Promise<void> {
async insert(flags: UnknownFlagReport[]): Promise<void> {
if (!flags.length) return;
const rows = flags.map(({ name, appName, seenAt, environment }) => ({
name,
app_name: appName,
seen_at: seenAt,
environment,
}));
const rows = flags.map(
({ name, appName, lastSeenAt, environment }) => ({
name,
app_name: appName,
seen_at: lastSeenAt,
environment,
}),
);
for (let i = 0; i < rows.length; i += MAX_INSERT_BATCH_SIZE) {
const chunk = rows.slice(i, i + MAX_INSERT_BATCH_SIZE);
@ -66,42 +84,67 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
}
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
let query = this.db(`${TABLE} AS uf`)
.select(
'uf.name',
'uf.app_name',
'uf.seen_at',
'uf.environment',
this.db.raw(
`(SELECT MAX(e.created_at)
FROM ${TABLE_EVENTS} AS e
WHERE e.feature_name = uf.name)
AS last_event_at`,
),
const base = this.db
.with('base', (qb) =>
qb
.from(`${TABLE} as uf`)
.leftJoin('features as f', 'f.name', 'uf.name')
.whereNull('f.name')
.select('uf.name', 'uf.app_name', 'uf.environment')
.max({ seen_at: 'uf.seen_at' })
.groupBy('uf.name', 'uf.app_name', 'uf.environment'),
)
.whereNotExists(
this.db('features as f')
.select(this.db.raw('1'))
.whereRaw('f.name = uf.name'),
.select(
'b.name',
this.db.raw('MAX(b.seen_at) as last_seen_at'),
this.db.raw(
`(SELECT MAX(e.created_at) FROM ${TABLE_EVENTS} e WHERE e.feature_name = b.name) as last_event_at`,
),
this.db.raw(`
jsonb_object_agg(
b.app_name,
(
SELECT jsonb_object_agg(env_row.environment, env_row.seen_at)
FROM (
SELECT environment, MAX(seen_at) AS seen_at
FROM base
WHERE name = b.name AND app_name = b.app_name
GROUP BY environment
) env_row
)
) as reports
`),
)
.from('base as b')
.groupBy('b.name');
let q = base;
if (orderBy) q = q.orderBy(orderBy);
if (limit) q = q.limit(limit);
const rows = await q;
return rows.map((r) => {
const reportsObj = r.reports ?? {};
const reports = Object.entries(reportsObj).map(
([appName, envs]) => ({
appName,
environments: Object.entries(
envs as Record<string, Date>,
).map(([environment, seenAt]) => ({
environment,
seenAt: new Date(seenAt),
})),
}),
);
if (orderBy) {
query = query.orderBy(orderBy);
}
if (limit) {
query = query.limit(limit);
}
const rows = await query;
return rows.map((row) => ({
name: row.name,
appName: row.app_name,
seenAt: row.seen_at,
environment: row.environment,
lastEventAt: row.last_event_at,
}));
return {
name: r.name,
lastSeenAt: r.last_seen_at,
lastEventAt: r.last_event_at,
reports,
};
});
}
async clear(hoursAgo: number): Promise<void> {

View File

@ -105,9 +105,18 @@ describe('should register unknown flags', () => {
expect(unknownFlags).toHaveLength(1);
expect(unknownFlags[0]).toMatchObject({
name: 'unknown_flag',
environment: 'development',
appName: 'demo',
seenAt: expect.any(Date),
lastSeenAt: expect.any(Date),
reports: [
{
appName: 'demo',
environments: [
{
environment: 'development',
seenAt: expect.any(Date),
},
],
},
],
});
expect(eventBus.emit).toHaveBeenCalledWith(
CLIENT_METRICS,
@ -167,9 +176,18 @@ describe('should register unknown flags', () => {
expect(unknownFlags).toHaveLength(1);
expect(unknownFlags[0]).toMatchObject({
name: 'unknown_flag_bulk',
environment: 'development',
appName: 'demo',
seenAt: expect.any(Date),
lastSeenAt: expect.any(Date),
reports: [
{
appName: 'demo',
environments: [
{
environment: 'development',
seenAt: expect.any(Date),
},
],
},
],
});
expect(eventBus.emit).toHaveBeenCalledWith(
CLIENT_METRICS,
@ -242,15 +260,35 @@ describe('should fetch unknown flags', () => {
expect(res.body.unknownFlags).toEqual([
expect.objectContaining({
name: 'unknown_flag_1',
environment: 'development',
appName: 'demo',
lastSeenAt: expect.any(String),
lastEventAt: null,
reports: [
{
appName: 'demo',
environments: [
{
environment: 'development',
seenAt: expect.any(String),
},
],
},
],
}),
expect.objectContaining({
name: 'unknown_flag_2',
environment: 'development',
appName: 'demo',
lastSeenAt: expect.any(String),
lastEventAt: null,
reports: [
{
appName: 'demo',
environments: [
{
environment: 'development',
seenAt: expect.any(String),
},
],
},
],
}),
]);
});
@ -312,9 +350,18 @@ describe('should fetch unknown flags', () => {
expect(res.body.unknownFlags).toEqual([
expect.objectContaining({
name: 'unknown_flag_2',
environment: 'development',
appName: 'demo',
lastEventAt: null,
lastSeenAt: expect.any(String),
reports: [
{
appName: 'demo',
environments: [
{
environment: 'development',
seenAt: expect.any(String),
},
],
},
],
}),
]);
});

View File

@ -4,7 +4,7 @@ export const unknownFlagSchema = {
$id: '#/components/schemas/unknownFlagSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'appName', 'seenAt', 'environment'],
required: ['name', 'lastSeenAt'],
description: 'An unknown flag report',
properties: {
name: {
@ -12,25 +12,13 @@ export const unknownFlagSchema = {
description: 'The name of the unknown flag.',
example: 'my-unknown-flag',
},
appName: {
type: 'string',
description:
'The name of the application that reported the unknown flag.',
example: 'my-app',
},
seenAt: {
lastSeenAt: {
type: 'string',
format: 'date-time',
description:
'The date and time when the unknown flag was reported.',
'The date and time when the unknown flag was last reported.',
example: '2023-10-01T12:00:00Z',
},
environment: {
type: 'string',
description:
'The environment in which the unknown flag was reported.',
example: 'production',
},
lastEventAt: {
type: 'string',
format: 'date-time',
@ -39,6 +27,48 @@ export const unknownFlagSchema = {
example: '2023-10-01T12:00:00Z',
nullable: true,
},
reports: {
type: 'array',
description: 'The list of reports for this unknown flag.',
items: {
type: 'object',
additionalProperties: false,
required: ['appName', 'environments'],
properties: {
appName: {
type: 'string',
description:
'The name of the application that reported the unknown flag.',
example: 'my-app',
},
environments: {
type: 'array',
description:
'The list of environments where this application reported the unknown flag.',
items: {
type: 'object',
additionalProperties: false,
required: ['environment', 'seenAt'],
properties: {
environment: {
type: 'string',
description:
'The environment in which the unknown flag was reported.',
example: 'production',
},
seenAt: {
type: 'string',
format: 'date-time',
description:
'The date and time when the unknown flag was last seen in this environment.',
example: '2023-10-01T12:00:00Z',
},
},
},
},
},
},
},
},
components: {},
} as const;