1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

chore: one unknown flag per row (#10590)

https://linear.app/unleash/issue/2-3833/show-one-flag-name-per-row

Groups unknown flags by flag name, showing a single flag name per row.
This greatly simplifies the way we show unknown flags.

Just to be safe, we're limiting the app names we're showing to 20, and
environments per app name to 10.

Required some plumbing.

### Basic example
<img width="1350" height="866" alt="image"
src="https://github.com/user-attachments/assets/ad8ee198-e5f8-45e4-8e3b-f2d8b7701cf9"
/>

### App name search example, with highlight

<img width="367" height="204" alt="image"
src="https://github.com/user-attachments/assets/a1cc27ee-9ca1-4980-a3af-c08302c1d617"
/>
This commit is contained in:
Nuno Góis 2025-09-02 10:47:02 +01:00 committed by GitHub
parent 2442e5c973
commit ed28d9f2b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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;