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:
parent
829c2c5bc3
commit
a2a7d5b4ff
@ -11,7 +11,7 @@ interface IUnknownFlagsSeenInUnleashCellProps extends ITimeAgoCellProps {
|
|||||||
unknownFlag: UnknownFlag;
|
unknownFlag: UnknownFlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnknownFlagsSeenInUnleashCell = ({
|
export const UnknownFlagsLastEventCell = ({
|
||||||
unknownFlag,
|
unknownFlag,
|
||||||
...props
|
...props
|
||||||
}: IUnknownFlagsSeenInUnleashCellProps) => {
|
}: IUnknownFlagsSeenInUnleashCellProps) => {
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -11,14 +11,14 @@ import { Search } from 'component/common/Search/Search';
|
|||||||
import { useSearch } from 'hooks/useSearch';
|
import { useSearch } from 'hooks/useSearch';
|
||||||
import { type UnknownFlag, useUnknownFlags } from './hooks/useUnknownFlags.js';
|
import { type UnknownFlag, useUnknownFlags } from './hooks/useUnknownFlags.js';
|
||||||
import theme from 'themes/theme.js';
|
import theme from 'themes/theme.js';
|
||||||
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell.js';
|
|
||||||
import { formatDateYMDHMS } from 'utils/formatDate.js';
|
import { formatDateYMDHMS } from 'utils/formatDate.js';
|
||||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell.js';
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell.js';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag.js';
|
import { useUiFlag } from 'hooks/useUiFlag.js';
|
||||||
import NotFound from 'component/common/NotFound/NotFound.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 { HelpIcon } from 'component/common/HelpIcon/HelpIcon.js';
|
||||||
import { UnknownFlagsActionsCell } from './UnknownFlagsActionsCell.js';
|
import { UnknownFlagsActionsCell } from './UnknownFlagsActionsCell.js';
|
||||||
|
import { UnknownFlagsLastReportedCell } from './UnknownFlagsLastReportedCell.js';
|
||||||
|
|
||||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
marginBottom: theme.spacing(3),
|
marginBottom: theme.spacing(3),
|
||||||
@ -51,36 +51,19 @@ export const UnknownFlagsTable = () => {
|
|||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Header: 'Application',
|
|
||||||
accessor: 'appName',
|
|
||||||
searchable: true,
|
|
||||||
minWidth: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: 'Environment',
|
|
||||||
accessor: 'environment',
|
|
||||||
searchable: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Header: (
|
Header: (
|
||||||
<StyledHeader>
|
<StyledHeader>
|
||||||
Reported
|
Last reported
|
||||||
<HelpIcon
|
<HelpIcon
|
||||||
tooltip={`Feature flags are reported when your SDK evaluates them and they don't exist in Unleash`}
|
tooltip={`Feature flags are reported when your SDK evaluates them and they don't exist in Unleash`}
|
||||||
size='16px'
|
size='16px'
|
||||||
/>
|
/>
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
),
|
),
|
||||||
accessor: 'seenAt',
|
accessor: 'lastSeenAt',
|
||||||
Cell: ({ value }) => (
|
Cell: UnknownFlagsLastReportedCell,
|
||||||
<TimeAgoCell
|
width: 170,
|
||||||
value={value}
|
|
||||||
title={(date) => `Reported: ${date}`}
|
|
||||||
dateFormat={formatDateYMDHMS}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
width: 150,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: (
|
Header: (
|
||||||
@ -98,7 +81,7 @@ export const UnknownFlagsTable = () => {
|
|||||||
}: {
|
}: {
|
||||||
row: { original: UnknownFlag };
|
row: { original: UnknownFlag };
|
||||||
}) => (
|
}) => (
|
||||||
<UnknownFlagsSeenInUnleashCell
|
<UnknownFlagsLastEventCell
|
||||||
unknownFlag={unknownFlag}
|
unknownFlag={unknownFlag}
|
||||||
dateFormat={formatDateYMDHMS}
|
dateFormat={formatDateYMDHMS}
|
||||||
/>
|
/>
|
||||||
@ -116,12 +99,34 @@ export const UnknownFlagsTable = () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
disableSortBy: true,
|
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({
|
const [initialState] = useState({
|
||||||
sortBy: [{ id: 'name', desc: false }],
|
sortBy: [{ id: 'name', desc: false }],
|
||||||
|
hiddenColumns: ['appNames', 'environments'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, getSearchText } = useSearch(
|
const { data, getSearchText } = useSearch(
|
||||||
@ -155,7 +160,7 @@ export const UnknownFlagsTable = () => {
|
|||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
header={
|
header={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Unknown flag report (${rows.length})`}
|
title={`Unknown flags (${rows.length})`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -187,7 +192,7 @@ export const UnknownFlagsTable = () => {
|
|||||||
>
|
>
|
||||||
<StyledAlert severity='info'>
|
<StyledAlert severity='info'>
|
||||||
<StyledAlertContent>
|
<StyledAlertContent>
|
||||||
<p>
|
<div>
|
||||||
<b>
|
<b>
|
||||||
Clean up unknown flags to keep your code and
|
Clean up unknown flags to keep your code and
|
||||||
configuration in sync
|
configuration in sync
|
||||||
@ -195,9 +200,9 @@ export const UnknownFlagsTable = () => {
|
|||||||
<br />
|
<br />
|
||||||
Unknown flags are feature flags that your SDKs tried to
|
Unknown flags are feature flags that your SDKs tried to
|
||||||
evaluate but which Unleash doesn't recognize.
|
evaluate but which Unleash doesn't recognize.
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<p>
|
<div>
|
||||||
<b>Unknown flags can include:</b>
|
<b>Unknown flags can include:</b>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
@ -209,14 +214,7 @@ export const UnknownFlagsTable = () => {
|
|||||||
unexpected names, unsupported by Unleash.
|
unexpected names, unsupported by Unleash.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
</StyledAlertContent>
|
</StyledAlertContent>
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
|
|
||||||
|
@ -5,12 +5,21 @@ import { useConditionalSWR } from 'hooks/api/getters/useConditionalSWR/useCondit
|
|||||||
import handleErrorResponses from 'hooks/api/getters/httpErrorResponseHandler';
|
import handleErrorResponses from 'hooks/api/getters/httpErrorResponseHandler';
|
||||||
import type { SWRConfiguration } from 'swr';
|
import type { SWRConfiguration } from 'swr';
|
||||||
|
|
||||||
|
type UnknownFlagEnvReport = {
|
||||||
|
environment: string;
|
||||||
|
seenAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnknownFlagAppReport = {
|
||||||
|
appName: string;
|
||||||
|
environments: UnknownFlagEnvReport[];
|
||||||
|
};
|
||||||
|
|
||||||
export type UnknownFlag = {
|
export type UnknownFlag = {
|
||||||
name: string;
|
name: string;
|
||||||
appName: string;
|
lastSeenAt: Date;
|
||||||
seenAt: Date;
|
lastEventAt?: Date;
|
||||||
environment: string;
|
reports: UnknownFlagAppReport[];
|
||||||
lastEventAt: Date;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type UnknownFlagsResponse = {
|
type UnknownFlagsResponse = {
|
||||||
|
@ -32,7 +32,7 @@ import {
|
|||||||
MetricsTranslator,
|
MetricsTranslator,
|
||||||
} from '../impact/metrics-translator.js';
|
} from '../impact/metrics-translator.js';
|
||||||
import { impactRegister } from '../impact/impact-register.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 {
|
export default class ClientMetricsServiceV2 {
|
||||||
private config: IUnleashConfig;
|
private config: IUnleashConfig;
|
||||||
@ -209,14 +209,14 @@ export default class ClientMetricsServiceV2 {
|
|||||||
`Got ${toggleNames.length} metrics (${invalidCount > 0 ? `${invalidCount} invalid` : 'all valid'}).`,
|
`Got ${toggleNames.length} metrics (${invalidCount > 0 ? `${invalidCount} invalid` : 'all valid'}).`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const unknownFlags: UnknownFlag[] = [];
|
const unknownFlags: UnknownFlagReport[] = [];
|
||||||
for (const [featureName, group] of metricsByToggle) {
|
for (const [featureName, group] of metricsByToggle) {
|
||||||
if (unknownSet.has(featureName)) {
|
if (unknownSet.has(featureName)) {
|
||||||
for (const m of group) {
|
for (const m of group) {
|
||||||
unknownFlags.push({
|
unknownFlags.push({
|
||||||
name: featureName,
|
name: featureName,
|
||||||
appName: m.appName,
|
appName: m.appName,
|
||||||
seenAt: m.timestamp,
|
lastSeenAt: m.timestamp,
|
||||||
environment: m.environment,
|
environment: m.environment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,67 @@
|
|||||||
import type {
|
import type {
|
||||||
IUnknownFlagsStore,
|
IUnknownFlagsStore,
|
||||||
UnknownFlag,
|
UnknownFlag,
|
||||||
|
UnknownFlagReport,
|
||||||
QueryParams,
|
QueryParams,
|
||||||
} from './unknown-flags-store.js';
|
} from './unknown-flags-store.js';
|
||||||
|
|
||||||
export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
|
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}`;
|
return `${flag.name}:${flag.appName}:${flag.environment}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async insert(flags: UnknownFlag[]): Promise<void> {
|
async insert(flags: UnknownFlagReport[]): Promise<void> {
|
||||||
this.unknownFlagMap.clear();
|
this.unknownFlagMap.clear();
|
||||||
for (const flag of flags) {
|
for (const flag of flags) {
|
||||||
this.unknownFlagMap.set(this.getKey(flag), flag);
|
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[]> {
|
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) {
|
if (orderBy) {
|
||||||
flags.sort((a, b) => {
|
flags.sort((a, b) => {
|
||||||
for (const { column, order } of orderBy) {
|
for (const { column, order } of orderBy) {
|
||||||
@ -36,7 +78,7 @@ export class FakeUnknownFlagsStore implements IUnknownFlagsStore {
|
|||||||
async clear(hoursAgo: number): Promise<void> {
|
async clear(hoursAgo: number): Promise<void> {
|
||||||
const cutoff = Date.now() - hoursAgo * 60 * 60 * 1000;
|
const cutoff = Date.now() - hoursAgo * 60 * 60 * 1000;
|
||||||
for (const [key, flag] of this.unknownFlagMap.entries()) {
|
for (const [key, flag] of this.unknownFlagMap.entries()) {
|
||||||
if (flag.seenAt.getTime() < cutoff) {
|
if (flag.lastSeenAt.getTime() < cutoff) {
|
||||||
this.unknownFlagMap.delete(key);
|
this.unknownFlagMap.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,11 @@ import type {
|
|||||||
IUnleashConfig,
|
IUnleashConfig,
|
||||||
} from '../../../types/index.js';
|
} from '../../../types/index.js';
|
||||||
import type { IUnleashStores } 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 {
|
export class UnknownFlagsService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -14,7 +18,7 @@ export class UnknownFlagsService {
|
|||||||
|
|
||||||
private unknownFlagsStore: IUnknownFlagsStore;
|
private unknownFlagsStore: IUnknownFlagsStore;
|
||||||
|
|
||||||
private unknownFlagsCache: Map<string, UnknownFlag>;
|
private unknownFlagsCache: Map<string, UnknownFlagReport>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{ unknownFlagsStore }: Pick<IUnleashStores, 'unknownFlagsStore'>,
|
{ unknownFlagsStore }: Pick<IUnleashStores, 'unknownFlagsStore'>,
|
||||||
@ -25,14 +29,14 @@ export class UnknownFlagsService {
|
|||||||
this.logger = config.getLogger(
|
this.logger = config.getLogger(
|
||||||
'/features/metrics/unknown-flags/unknown-flags-service.ts',
|
'/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}`;
|
return `${flag.name}:${flag.appName}:${flag.environment}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(unknownFlags: UnknownFlag[]) {
|
register(unknownFlags: UnknownFlagReport[]) {
|
||||||
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return;
|
if (!this.flagResolver.isEnabled('reportUnknownFlags')) return;
|
||||||
for (const flag of unknownFlags) {
|
for (const flag of unknownFlags) {
|
||||||
const key = this.getKey(flag);
|
const key = this.getKey(flag);
|
||||||
|
@ -5,12 +5,28 @@ const TABLE = 'unknown_flags';
|
|||||||
const TABLE_EVENTS = 'events';
|
const TABLE_EVENTS = 'events';
|
||||||
const MAX_INSERT_BATCH_SIZE = 100;
|
const MAX_INSERT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
type UnknownFlagEnvReport = {
|
||||||
|
environment: string;
|
||||||
|
seenAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnknownFlagAppReport = {
|
||||||
|
appName: string;
|
||||||
|
environments: UnknownFlagEnvReport[];
|
||||||
|
};
|
||||||
|
|
||||||
export type UnknownFlag = {
|
export type UnknownFlag = {
|
||||||
name: string;
|
name: string;
|
||||||
appName: string;
|
lastSeenAt: Date;
|
||||||
seenAt: Date;
|
|
||||||
environment: string;
|
|
||||||
lastEventAt?: Date;
|
lastEventAt?: Date;
|
||||||
|
reports: UnknownFlagAppReport[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnknownFlagReport = {
|
||||||
|
name: string;
|
||||||
|
appName: string;
|
||||||
|
lastSeenAt: Date;
|
||||||
|
environment: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryParams = {
|
export type QueryParams = {
|
||||||
@ -22,7 +38,7 @@ export type QueryParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface IUnknownFlagsStore {
|
export interface IUnknownFlagsStore {
|
||||||
insert(flags: UnknownFlag[]): Promise<void>;
|
insert(flags: UnknownFlagReport[]): Promise<void>;
|
||||||
getAll(params?: QueryParams): Promise<UnknownFlag[]>;
|
getAll(params?: QueryParams): Promise<UnknownFlag[]>;
|
||||||
clear(hoursAgo: number): Promise<void>;
|
clear(hoursAgo: number): Promise<void>;
|
||||||
deleteAll(): Promise<void>;
|
deleteAll(): Promise<void>;
|
||||||
@ -39,15 +55,17 @@ export class UnknownFlagsStore implements IUnknownFlagsStore {
|
|||||||
this.logger = getLogger('unknown-flags-store.ts');
|
this.logger = getLogger('unknown-flags-store.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
async insert(flags: UnknownFlag[]): Promise<void> {
|
async insert(flags: UnknownFlagReport[]): Promise<void> {
|
||||||
if (!flags.length) return;
|
if (!flags.length) return;
|
||||||
|
|
||||||
const rows = flags.map(({ name, appName, seenAt, environment }) => ({
|
const rows = flags.map(
|
||||||
name,
|
({ name, appName, lastSeenAt, environment }) => ({
|
||||||
app_name: appName,
|
name,
|
||||||
seen_at: seenAt,
|
app_name: appName,
|
||||||
environment,
|
seen_at: lastSeenAt,
|
||||||
}));
|
environment,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i += MAX_INSERT_BATCH_SIZE) {
|
for (let i = 0; i < rows.length; i += MAX_INSERT_BATCH_SIZE) {
|
||||||
const chunk = rows.slice(i, 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[]> {
|
async getAll({ limit, orderBy }: QueryParams = {}): Promise<UnknownFlag[]> {
|
||||||
let query = this.db(`${TABLE} AS uf`)
|
const base = this.db
|
||||||
.select(
|
.with('base', (qb) =>
|
||||||
'uf.name',
|
qb
|
||||||
'uf.app_name',
|
.from(`${TABLE} as uf`)
|
||||||
'uf.seen_at',
|
.leftJoin('features as f', 'f.name', 'uf.name')
|
||||||
'uf.environment',
|
.whereNull('f.name')
|
||||||
this.db.raw(
|
.select('uf.name', 'uf.app_name', 'uf.environment')
|
||||||
`(SELECT MAX(e.created_at)
|
.max({ seen_at: 'uf.seen_at' })
|
||||||
FROM ${TABLE_EVENTS} AS e
|
.groupBy('uf.name', 'uf.app_name', 'uf.environment'),
|
||||||
WHERE e.feature_name = uf.name)
|
|
||||||
AS last_event_at`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.whereNotExists(
|
.select(
|
||||||
this.db('features as f')
|
'b.name',
|
||||||
.select(this.db.raw('1'))
|
this.db.raw('MAX(b.seen_at) as last_seen_at'),
|
||||||
.whereRaw('f.name = uf.name'),
|
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) {
|
return {
|
||||||
query = query.orderBy(orderBy);
|
name: r.name,
|
||||||
}
|
lastSeenAt: r.last_seen_at,
|
||||||
|
lastEventAt: r.last_event_at,
|
||||||
if (limit) {
|
reports,
|
||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(hoursAgo: number): Promise<void> {
|
async clear(hoursAgo: number): Promise<void> {
|
||||||
|
@ -105,9 +105,18 @@ describe('should register unknown flags', () => {
|
|||||||
expect(unknownFlags).toHaveLength(1);
|
expect(unknownFlags).toHaveLength(1);
|
||||||
expect(unknownFlags[0]).toMatchObject({
|
expect(unknownFlags[0]).toMatchObject({
|
||||||
name: 'unknown_flag',
|
name: 'unknown_flag',
|
||||||
environment: 'development',
|
lastSeenAt: expect.any(Date),
|
||||||
appName: 'demo',
|
reports: [
|
||||||
seenAt: expect.any(Date),
|
{
|
||||||
|
appName: 'demo',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environment: 'development',
|
||||||
|
seenAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(eventBus.emit).toHaveBeenCalledWith(
|
expect(eventBus.emit).toHaveBeenCalledWith(
|
||||||
CLIENT_METRICS,
|
CLIENT_METRICS,
|
||||||
@ -167,9 +176,18 @@ describe('should register unknown flags', () => {
|
|||||||
expect(unknownFlags).toHaveLength(1);
|
expect(unknownFlags).toHaveLength(1);
|
||||||
expect(unknownFlags[0]).toMatchObject({
|
expect(unknownFlags[0]).toMatchObject({
|
||||||
name: 'unknown_flag_bulk',
|
name: 'unknown_flag_bulk',
|
||||||
environment: 'development',
|
lastSeenAt: expect.any(Date),
|
||||||
appName: 'demo',
|
reports: [
|
||||||
seenAt: expect.any(Date),
|
{
|
||||||
|
appName: 'demo',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environment: 'development',
|
||||||
|
seenAt: expect.any(Date),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(eventBus.emit).toHaveBeenCalledWith(
|
expect(eventBus.emit).toHaveBeenCalledWith(
|
||||||
CLIENT_METRICS,
|
CLIENT_METRICS,
|
||||||
@ -242,15 +260,35 @@ describe('should fetch unknown flags', () => {
|
|||||||
expect(res.body.unknownFlags).toEqual([
|
expect(res.body.unknownFlags).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'unknown_flag_1',
|
name: 'unknown_flag_1',
|
||||||
environment: 'development',
|
lastSeenAt: expect.any(String),
|
||||||
appName: 'demo',
|
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
appName: 'demo',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environment: 'development',
|
||||||
|
seenAt: expect.any(String),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'unknown_flag_2',
|
name: 'unknown_flag_2',
|
||||||
environment: 'development',
|
lastSeenAt: expect.any(String),
|
||||||
appName: 'demo',
|
|
||||||
lastEventAt: null,
|
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(res.body.unknownFlags).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'unknown_flag_2',
|
name: 'unknown_flag_2',
|
||||||
environment: 'development',
|
lastSeenAt: expect.any(String),
|
||||||
appName: 'demo',
|
reports: [
|
||||||
lastEventAt: null,
|
{
|
||||||
|
appName: 'demo',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environment: 'development',
|
||||||
|
seenAt: expect.any(String),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,7 @@ export const unknownFlagSchema = {
|
|||||||
$id: '#/components/schemas/unknownFlagSchema',
|
$id: '#/components/schemas/unknownFlagSchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['name', 'appName', 'seenAt', 'environment'],
|
required: ['name', 'lastSeenAt'],
|
||||||
description: 'An unknown flag report',
|
description: 'An unknown flag report',
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
@ -12,25 +12,13 @@ export const unknownFlagSchema = {
|
|||||||
description: 'The name of the unknown flag.',
|
description: 'The name of the unknown flag.',
|
||||||
example: 'my-unknown-flag',
|
example: 'my-unknown-flag',
|
||||||
},
|
},
|
||||||
appName: {
|
lastSeenAt: {
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'The name of the application that reported the unknown flag.',
|
|
||||||
example: 'my-app',
|
|
||||||
},
|
|
||||||
seenAt: {
|
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
description:
|
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',
|
example: '2023-10-01T12:00:00Z',
|
||||||
},
|
},
|
||||||
environment: {
|
|
||||||
type: 'string',
|
|
||||||
description:
|
|
||||||
'The environment in which the unknown flag was reported.',
|
|
||||||
example: 'production',
|
|
||||||
},
|
|
||||||
lastEventAt: {
|
lastEventAt: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
@ -39,6 +27,48 @@ export const unknownFlagSchema = {
|
|||||||
example: '2023-10-01T12:00:00Z',
|
example: '2023-10-01T12:00:00Z',
|
||||||
nullable: true,
|
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: {},
|
components: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
Loading…
Reference in New Issue
Block a user