1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-12 13:48:35 +02:00

feat: custom metrics (#10022)

This commit is contained in:
David Leek 2025-05-22 08:58:54 +01:00 committed by GitHub
parent 76b201e40e
commit 9fca29f254
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 610 additions and 0 deletions

View File

@ -0,0 +1,227 @@
import { Line } from 'react-chartjs-2';
import type { IMetricsCount } from 'hooks/api/getters/useMetricCounters/useMetricCounters';
import type {} from 'chart.js';
import type { Theme } from '@mui/material/styles/createTheme';
import {
useLocationSettings,
type ILocationSettings,
} from 'hooks/useLocationSettings';
import { formatDateHM } from 'utils/formatDate';
import { useMemo } from 'react';
import { useTheme } from '@mui/material';
import { CyclicIterator } from 'utils/cyclicIterator';
import {
CategoryScale,
Chart as ChartJS,
type ChartDataset,
type ChartOptions,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js';
type ChartDatasetType = ChartDataset<'line', IPoint[]>;
export interface IPoint {
x: Date;
y: number;
}
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
TimeScale,
Legend,
Tooltip,
Title,
);
export const createChartOptions = (
theme: Theme,
metrics: IMetricsCount[],
hoursBack: number,
locationSettings: ILocationSettings,
): ChartOptions<'line'> => {
return {
locale: locationSettings.locale,
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
color: theme.palette.text.secondary,
scales: {
y: {
type: 'linear',
title: {
display: true,
text: 'Count',
color: theme.palette.text.secondary,
},
suggestedMin: 0,
ticks: { precision: 0, color: theme.palette.text.secondary },
grid: {
color: theme.palette.divider,
borderColor: theme.palette.divider,
},
},
x: {
type: 'time',
time: { unit: 'minute' },
grid: { display: false },
ticks: {
callback: (_, i, data) =>
formatDateHM(data[i].value, locationSettings.locale),
color: theme.palette.text.secondary,
},
},
},
};
};
export function buildMetricKey({ name, labels }: IMetricsCount): string {
if (!labels || Object.keys(labels).length === 0) {
return encodeURIComponent(name);
}
const labelParts = Object.entries(labels)
.filter(([, v]) => v != null && v !== '') // robustness: ignore empties
.sort(([a], [b]) => a.localeCompare(b)) // robustness: deterministic order
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
return [encodeURIComponent(name), ...labelParts].join('_');
}
const createChartPoint = (value: number, timestamp: Date): IPoint => {
return {
x: timestamp,
y: value,
};
};
class ItemPicker<T> {
private items: CyclicIterator<T>;
private picked: Map<string, T> = new Map();
constructor(items: T[]) {
this.items = new CyclicIterator<T>(items);
}
public pick(key: string): T {
if (!this.picked.has(key)) {
this.picked.set(key, this.items.next());
}
return this.picked.get(key)!;
}
}
const toValues = (
metrics: IMetricsCount[],
selectedLabels: string[],
selectedValues: string[],
) => {
return metrics.reduce(
(acc, metric) => {
for (const [key, value] of Object.entries(metric.labels)) {
const labelKey = buildMetricKey(metric);
if (!acc[labelKey]) {
acc[labelKey] = [];
}
acc[labelKey].push({
count: metric.value,
timestamp: metric.timestamp,
});
}
return acc;
},
{} as Record<string, { count: number; timestamp: Date }[]>,
);
};
export const createChartData = (
theme: Theme,
metrics: IMetricsCount[],
selectedLabels: string[],
selectedLabelValues: string[],
locationSettings: ILocationSettings,
): ChartDatasetType[] => {
const colorPicker = new ItemPicker([
theme.palette.success,
theme.palette.error,
theme.palette.primary,
theme.palette.warning,
theme.palette.info,
theme.palette.secondary,
]);
const labelValues = toValues(metrics, selectedLabels, selectedLabelValues);
const datasets = Object.entries(labelValues).map(([key, values]) => {
const color = colorPicker.pick(key);
return {
label: key,
data: values.map((value) =>
createChartPoint(value.count, value.timestamp),
),
borderColor: color.main,
backgroundColor: color.main,
fill: false,
tension: 0.1,
pointRadius: 0,
pointHoverRadius: 5,
};
});
return datasets;
};
export const ExploreCounterChart = ({
selectedLabels,
selectedLabelValues,
filteredCounters,
counter,
setCounter,
}: {
selectedLabels: string[];
selectedLabelValues: string[];
filteredCounters: IMetricsCount[];
counter: string | undefined;
setCounter: (counter: string) => void;
}) => {
const theme = useTheme();
const { locationSettings } = useLocationSettings();
const options = useMemo(() => {
return createChartOptions(
theme,
filteredCounters,
10,
locationSettings,
);
}, [theme, filteredCounters, locationSettings]);
const data = useMemo(() => {
return {
datasets: createChartData(
theme,
filteredCounters,
selectedLabels,
selectedLabelValues,
locationSettings,
),
};
}, [theme, filteredCounters, locationSettings]);
return (
<div style={{ height: 400 }}>
<Line
options={options}
data={data}
aria-label='Explore Counters Metrics Chart'
/>
</div>
);
};

View File

@ -0,0 +1,73 @@
import {
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
type SelectChangeEvent,
} from '@mui/material';
import { SelectCounterLabel } from './SelectCounterLabel.js';
interface IExploreCounterFilter {
counter: string | undefined;
setCounter: (counter: string) => void;
counterNames: string[] | undefined;
labels: Record<string, string[]> | undefined;
selectLabel: (label: string) => void;
unselectLabel: (label: string) => void;
selectLabelValue: (value: string) => void;
unselectLabelValue: (value: string) => void;
}
export const ExploreCounterFilter = ({
counterNames,
labels,
counter,
setCounter,
selectLabel,
unselectLabel,
selectLabelValue,
unselectLabelValue,
}: IExploreCounterFilter) => {
const counterChanged = (event: SelectChangeEvent<string>) => {
const selectedCounter = event.target.value as string;
setCounter(selectedCounter);
};
return (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Grid container spacing={2}>
<FormControl>
<InputLabel id='counter-label' size='small'>
Counter
</InputLabel>
<Select
label='Counter'
labelId='counter-label'
id='counter-select'
value={counter}
onChange={counterChanged}
variant='outlined'
size='small'
sx={{ width: 200, maxWidth: '100%' }}
>
{counterNames?.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
<SelectCounterLabel
labels={labels}
selectLabel={selectLabel}
unselectLabel={unselectLabel}
selectLabelValue={selectLabelValue}
unselectLabelValue={unselectLabelValue}
/>
</Grid>
</Grid>
</Grid>
);
};

View File

@ -0,0 +1,137 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import {
type IMetricsCount,
useMetricCounters,
} from 'hooks/api/getters/useMetricCounters/useMetricCounters';
import { usePageTitle } from 'hooks/usePageTitle';
import { useEffect, useMemo, useState } from 'react';
import { ExploreCounterFilter } from './ExploreCounterFilter.js';
import { ExploreCounterChart } from './ExploreCounterChart.js';
const mapCounterNames = (metrics: IMetricsCount[]) => {
return metrics.reduce((acc, metric) => {
if (!acc.includes(metric.name)) {
acc.push(metric.name);
}
return acc;
}, [] as string[]);
};
const mapLabels = (metrics: IMetricsCount[]) => {
return metrics.reduce(
(acc, metric) => {
for (const [key, value] of Object.entries(metric.labels)) {
if (!acc[key]) {
acc[key] = [];
}
if (!acc[key].includes(value)) {
acc[key].push(value);
}
}
return acc;
},
{} as Record<string, string[]>,
);
};
export const ExploreCounters = () => {
usePageTitle('Explore custom metrics');
const data = useMetricCounters();
const [counter, setCounter] = useState<string | undefined>(undefined);
const [counterNames, setCounterNames] = useState<string[] | undefined>(
undefined,
);
const [labels, setLabels] = useState<Record<string, string[]> | undefined>(
undefined,
);
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [selectedLabelValues, setSelectedLabelValues] = useState<string[]>(
[],
);
const filteredCounters = useMemo(() => {
return data?.counters?.metrics?.filter((metric) => {
if (counter && metric.name !== counter) {
return false;
}
if (selectedLabels.length > 0) {
const labels = Object.keys(metric.labels);
return selectedLabels.every(
(label) =>
labels.includes(label) &&
(selectedLabelValues.length === 0 ||
selectedLabelValues.includes(
`${label}::${metric.labels[label]}`,
)),
);
}
return false;
});
}, [data, counter, selectedLabels, selectedLabelValues]);
useEffect(() => {
setCounterNames(mapCounterNames(data.counters.metrics));
const labelMetrics = data.counters.metrics.filter((metric) => {
return counter && metric.name === counter;
});
const counterLabels = mapLabels(labelMetrics);
setLabels(counterLabels);
}, [data.counters, filteredCounters, counter]);
const selectLabel = (label: string) => {
setSelectedLabels((prev) => {
if (prev.includes(label)) {
return prev.filter((l) => l !== label);
}
return [...prev, label];
});
};
const unselectLabel = (label: string) => {
setSelectedLabels((prev) => {
return prev.filter((l) => l !== label);
});
};
const selectLabelValue = (label: string) => {
setSelectedLabelValues((prev) => {
if (prev.includes(label)) {
return prev.filter((l) => l !== label);
}
return [...prev, label];
});
};
const unselectLabelValue = (label: string) => {
setSelectedLabelValues((prev) => {
return prev.filter((l) => l !== label);
});
};
return (
<>
<PageContent
header={<PageHeader title={`Explore custom metrics`} />}
>
<ExploreCounterFilter
selectLabel={selectLabel}
unselectLabel={unselectLabel}
selectLabelValue={selectLabelValue}
unselectLabelValue={unselectLabelValue}
counter={counter}
setCounter={setCounter}
counterNames={counterNames}
labels={labels}
/>
{counter &&
selectedLabels.length > 0 &&
filteredCounters.length > 0 && (
<ExploreCounterChart
selectedLabels={selectedLabels}
selectedLabelValues={selectedLabelValues}
filteredCounters={filteredCounters}
counter={counter}
setCounter={setCounter}
/>
)}
</PageContent>
</>
);
};

View File

@ -0,0 +1,107 @@
import {
FormControl,
InputLabel,
MenuItem,
Select,
type SelectChangeEvent,
} from '@mui/material';
import { useState } from 'react';
const getLabelNames = (labels: Record<string, string[]>) => {
return Object.keys(labels);
};
const getLabelValues = (label: string, labels: Record<string, string[]>) => {
return labels[label];
};
export const SelectCounterLabel = ({
labels,
unselectLabel,
selectLabel,
unselectLabelValue,
selectLabelValue,
}: {
labels: Record<string, string[]> | undefined;
unselectLabel: (label: string) => void;
selectLabel: (label: string) => void;
unselectLabelValue: (labelValue: string) => void;
selectLabelValue: (labelValue: string) => void;
}) => {
const [label, setLabel] = useState<string | undefined>(undefined);
const [labelValue, setLabelValue] = useState<string | undefined>(undefined);
const labelChanged = (event: SelectChangeEvent<string>) => {
unselectLabel(label as string);
selectLabel(event.target.value as string);
const selectedLabel = event.target.value as string;
setLabel(selectedLabel);
};
const labelValueChanged = (event: SelectChangeEvent<string>) => {
unselectLabelValue(labelValue as string);
const newValue = event.target.value as string;
if (newValue === '') {
setLabelValue(undefined);
return;
}
selectLabelValue(newValue);
setLabelValue(newValue);
};
return (
<>
<FormControl>
<InputLabel id='labels-label' size='small'>
Label
</InputLabel>
<Select
label='Label'
labelId='labels-label'
id='label-select'
value={label}
onChange={labelChanged}
variant='outlined'
size='small'
sx={{ width: 200, maxWidth: '100%' }}
>
{labels
? getLabelNames(labels)?.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))
: null}
</Select>
</FormControl>
{label ? (
<FormControl>
<InputLabel id='label-value-label' size='small'>
Label value
</InputLabel>
<Select
label='Label value'
labelId='label-value-label'
id='label-value-select'
value={labelValue}
onChange={labelValueChanged}
variant='outlined'
size='small'
sx={{ width: 200, maxWidth: '100%' }}
>
<MenuItem key='all' value=''>
All
</MenuItem>
{labels
? getLabelValues(label, labels)?.map((option) => (
<MenuItem
key={option}
value={`${label}::${option}`}
>
{option}
</MenuItem>
))
: null}
</Select>
</FormControl>
) : null}
</>
);
};

View File

@ -34,6 +34,7 @@ import PersonalDashboardIcon from '@mui/icons-material/DashboardOutlined';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import PlaygroundIcon from '@mui/icons-material/AutoFixNormal';
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
import RocketLaunchIcon from '@mui/icons-material/RocketLaunchOutlined';
// TODO: move to routes
const icons: Record<
@ -82,6 +83,7 @@ const icons: Record<
'/personal': PersonalDashboardIcon,
'/projects': ProjectIcon,
'/playground': PlaygroundIcon,
'/custom-metrics': RocketLaunchIcon,
GitHub: GitHubIcon,
Documentation: LibraryBooksIcon,
};

View File

@ -152,6 +152,16 @@ exports[`returns all baseRoutes 1`] = `
"title": "Applications",
"type": "protected",
},
{
"component": [Function],
"flag": "customMetrics",
"menu": {
"main": true,
},
"path": "/custom-metrics",
"title": "Custom metrics",
"type": "protected",
},
{
"component": [Function],
"menu": {},

View File

@ -50,6 +50,7 @@ import { PersonalDashboard } from '../personalDashboard/PersonalDashboard.jsx';
import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement';
import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate';
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js';
export const routes: IRoute[] = [
// Splash
@ -175,6 +176,16 @@ export const routes: IRoute[] = [
menu: { main: true },
},
// Counters
{
path: '/custom-metrics',
title: 'Custom metrics',
component: ExploreCounters,
type: 'protected',
menu: { main: true },
flag: 'customMetrics',
},
// Context
{
path: '/context/create',

View File

@ -0,0 +1,42 @@
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR.js';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js';
import { useMemo } from 'react';
export interface IMetricsCount {
name: string;
value: number;
timestamp: Date;
labels: Record<string, string>;
}
export interface IMetricsResponse {
metrics: IMetricsCount[];
count: number;
metricNames: string[];
}
export const useMetricCounters = () => {
const { data, error, mutate } = useConditionalSWR<IMetricsResponse>(
true,
{ metrics: [], count: 0, metricNames: [] },
formatApiPath('api/admin/custom-metrics'),
fetcher,
);
return useMemo(
() => ({
counters: data ?? { metrics: [], count: 0, metricNames: [] },
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Metric counters'))
.then((res) => res.json());
};

View File

@ -91,6 +91,7 @@ export type UiFlags = {
registerFrontendClient?: boolean;
featureLinks?: boolean;
projectLinkTemplates?: boolean;
customMetrics?: boolean;
};
export interface IVersionInfo {