1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-04 00:18:01 +01:00

perf: Simplify queries to prometheus (#2706)

## About the changes
This PR improves our queries to Prometheus (instead of making multiple queries do only one) and improves the UI and the code. 

The reports aggregate all HTTP methods (GET, POST, PUT, DELETE, OPTIONS, HEAD and PATCH) without distinction under the same "endpoint" (a relative path inside unleash up to a certain depth)

Co-authored-by: Nuno Góis <nuno@getunleash.ai>
This commit is contained in:
Gastón Fournier 2022-12-19 17:06:59 +01:00 committed by GitHub
parent 2c15841af4
commit 4b519ead4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 180 additions and 179 deletions

View File

@ -3,6 +3,7 @@ import { Mermaid } from 'component/common/Mermaid/Mermaid';
import { useInstanceMetrics } from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics'; import { useInstanceMetrics } from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, styled } from '@mui/material'; import { Alert, styled } from '@mui/material';
import { unknownify } from 'utils/unknownify';
const StyledMermaid = styled(Mermaid)(({ theme }) => ({ const StyledMermaid = styled(Mermaid)(({ theme }) => ({
'#mermaid .node rect': { '#mermaid .node rect': {
@ -11,6 +12,13 @@ const StyledMermaid = styled(Mermaid)(({ theme }) => ({
}, },
})); }));
const isRecent = (value: ResultValue) => {
const threshold = 60000; // ten minutes
return value[0] * 1000 > new Date().getTime() - threshold;
};
type ResultValue = [number, string];
interface INetworkApp { interface INetworkApp {
label?: string; label?: string;
reqs: string; reqs: string;
@ -20,26 +28,33 @@ interface INetworkApp {
export const NetworkOverview = () => { export const NetworkOverview = () => {
usePageTitle('Network - Overview'); usePageTitle('Network - Overview');
const { metrics } = useInstanceMetrics(); const { metrics } = useInstanceMetrics();
const results = metrics?.data?.result;
const apps: INetworkApp[] = []; const apps: INetworkApp[] = [];
if (Boolean(metrics)) {
Object.keys(metrics).forEach(metric => { if (results) {
apps.push( apps.push(
...( ...(
metrics[metric].data?.result results
?.map(result => ({ ?.map(result => {
label: result.metric?.appName, const values = (result.values || []) as ResultValue[];
reqs: parseFloat( const data =
result.values?.[ values.filter(value => isRecent(value)) || [];
result.values?.length - 1 let reqs = 0;
][1].toString() || '0' if (data.length) {
).toFixed(2), reqs = parseFloat(data[data.length - 1][1]);
type: metric.split('Metrics')[0], }
})) return {
.filter(app => app.label !== 'undefined') || [] label: unknownify(result.metric?.appName),
reqs: reqs.toFixed(2),
type: unknownify(
result.metric?.endpoint?.split('/')[2]
),
};
})
.filter(app => app.label !== 'unknown') || []
).filter(app => app.reqs !== '0.00') ).filter(app => app.reqs !== '0.00')
); );
});
} }
const graph = ` const graph = `

View File

@ -1,13 +1,9 @@
import { import { useInstanceMetrics } from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
InstanceMetrics,
useInstanceMetrics,
} from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
import { useMemo, VFC } from 'react'; import { useMemo, VFC } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { import {
CategoryScale, CategoryScale,
Chart as ChartJS, Chart as ChartJS,
ChartData,
ChartDataset, ChartDataset,
ChartOptions, ChartOptions,
Legend, Legend,
@ -26,11 +22,12 @@ import theme from 'themes/theme';
import { formatDateHM } from 'utils/formatDate'; import { formatDateHM } from 'utils/formatDate';
import { RequestsPerSecondSchema } from 'openapi'; import { RequestsPerSecondSchema } from 'openapi';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { Alert, PaletteColor } from '@mui/material'; import { Alert } from '@mui/material';
import { Box } from '@mui/system'; import { Box } from '@mui/system';
import { CyclicIterator } from 'utils/cyclicIterator'; import { CyclicIterator } from 'utils/cyclicIterator';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { usePageTitle } from 'hooks/usePageTitle'; import { usePageTitle } from 'hooks/usePageTitle';
import { unknownify } from 'utils/unknownify';
interface IPoint { interface IPoint {
x: number; x: number;
@ -38,22 +35,22 @@ interface IPoint {
} }
type ChartDatasetType = ChartDataset<'line', IPoint[]>; type ChartDatasetType = ChartDataset<'line', IPoint[]>;
type ChartDataType = ChartData<'line', IPoint[], string>;
type ResultValue = [number, string];
const createChartPoints = ( const createChartPoints = (
values: Array<Array<number | string>>, values: ResultValue[],
y: (m: string) => number y: (m: string) => number
): IPoint[] => { ): IPoint[] => {
return values.map(row => ({ return values.map(row => ({
x: row[0] as number, x: row[0],
y: y(row[1] as string), y: y(row[1]),
})); }));
}; };
const createInstanceChartOptions = ( const createInstanceChartOptions = (
metrics: InstanceMetrics,
locationSettings: ILocationSettings locationSettings: ILocationSettings
): ChartOptions<'line'> => { ): ChartOptions<'line'> => ({
return {
locale: locationSettings.locale, locale: locationSettings.locale,
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -63,7 +60,7 @@ const createInstanceChartOptions = (
}, },
plugins: { plugins: {
tooltip: { tooltip: {
backgroundColor: 'white', backgroundColor: theme.palette.background.paper,
bodyColor: theme.palette.text.primary, bodyColor: theme.palette.text.primary,
titleColor: theme.palette.grey[700], titleColor: theme.palette.grey[700],
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
@ -121,20 +118,41 @@ const createInstanceChartOptions = (
}, },
}, },
}, },
}; });
};
const toChartData = ( class ItemPicker<T> {
rps: RequestsPerSecondSchema, private items: CyclicIterator<T>;
color: PaletteColor, private picked: Map<string, T> = new Map();
label: (name: string) => string constructor(items: T[]) {
): ChartDatasetType[] => { this.items = new CyclicIterator<T>(items);
if (rps.data?.result) { }
return rps.data.result.map(dataset => ({
label: label(dataset.metric?.appName || 'unknown'), public pick(key: string): T {
if (!this.picked.has(key)) {
this.picked.set(key, this.items.next());
}
return this.picked.get(key)!;
}
}
const toChartData = (rps?: RequestsPerSecondSchema): ChartDatasetType[] => {
if (rps?.data?.result) {
const colorPicker = new ItemPicker([
theme.palette.success,
theme.palette.error,
theme.palette.primary,
theme.palette.warning,
]);
return rps.data.result.map(dataset => {
const endpoint = unknownify(dataset.metric?.endpoint);
const appName = unknownify(dataset.metric?.appName);
const color = colorPicker.pick(endpoint);
const values = (dataset.values || []) as ResultValue[];
return {
label: `${endpoint}: ${appName}`,
borderColor: color.main, borderColor: color.main,
backgroundColor: color.main, backgroundColor: color.main,
data: createChartPoints(dataset.values || [], y => parseFloat(y)), data: createChartPoints(values, y => parseFloat(y)),
elements: { elements: {
point: { point: {
radius: 4, radius: 4,
@ -144,44 +162,23 @@ const toChartData = (
borderDash: [8, 4], borderDash: [8, 4],
}, },
}, },
})); };
});
} }
return []; return [];
}; };
const createInstanceChartData = (metrics?: InstanceMetrics): ChartDataType => {
if (metrics) {
const colors = new CyclicIterator<PaletteColor>([
theme.palette.success,
theme.palette.error,
theme.palette.primary,
]);
let datasets: ChartDatasetType[] = [];
for (let key in metrics) {
datasets = datasets.concat(
toChartData(
metrics[key],
colors.next(),
metricName => `${metricName}: ${key}`
)
);
}
return { datasets };
}
return { datasets: [] };
};
export const NetworkTraffic: VFC = () => { export const NetworkTraffic: VFC = () => {
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();
const { metrics } = useInstanceMetrics(); const { metrics } = useInstanceMetrics();
const options = useMemo(() => { const options = useMemo(() => {
return createInstanceChartOptions(metrics, locationSettings); return createInstanceChartOptions(locationSettings);
}, [metrics, locationSettings]); }, [locationSettings]);
usePageTitle('Network - Traffic'); usePageTitle('Network - Traffic');
const data = useMemo(() => { const data = useMemo(() => {
return createInstanceChartData(metrics); return { datasets: toChartData(metrics) };
}, [metrics, locationSettings]); }, [metrics, locationSettings]);
return ( return (

View File

@ -1,6 +1,6 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
import { useEffect } from 'react'; import { useRef, useEffect } from 'react';
const StyledMermaid = styled('div')(({ theme }) => ({ const StyledMermaid = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -11,6 +11,7 @@ const StyledMermaid = styled('div')(({ theme }) => ({
})); }));
mermaid.initialize({ mermaid.initialize({
startOnLoad: false,
theme: 'default', theme: 'default',
themeCSS: ` themeCSS: `
.clusters #_ rect { .clusters #_ rect {
@ -21,23 +22,19 @@ mermaid.initialize({
}); });
interface IMermaidProps { interface IMermaidProps {
className?: string;
children: string; children: string;
} }
export const Mermaid = ({ className = '', children }: IMermaidProps) => { export const Mermaid = ({ children, ...props }: IMermaidProps) => {
const mermaidRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
mermaid.render('mermaid', children, (svgCode: string) => { mermaid.render('mermaid', children, svgCode => {
const mermaidDiv = document.querySelector('.mermaid'); if (mermaidRef.current) {
if (mermaidDiv) { mermaidRef.current.innerHTML = svgCode;
mermaidDiv.innerHTML = svgCode;
} }
}); });
}, [children]); }, [children]);
return ( return <StyledMermaid ref={mermaidRef} {...props} />;
<StyledMermaid className={`mermaid ${className}`}>
{children}
</StyledMermaid>
);
}; };

View File

@ -4,12 +4,8 @@ import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { RequestsPerSecondSchema } from 'openapi'; import { RequestsPerSecondSchema } from 'openapi';
export interface InstanceMetrics {
[key: string]: RequestsPerSecondSchema;
}
export interface IInstanceMetricsResponse { export interface IInstanceMetricsResponse {
metrics: InstanceMetrics; metrics: RequestsPerSecondSchema;
refetch: () => void; refetch: () => void;

View File

@ -25,6 +25,12 @@ export interface RequestsPerSecondSchemaDataResultInnerMetric {
* @memberof RequestsPerSecondSchemaDataResultInnerMetric * @memberof RequestsPerSecondSchemaDataResultInnerMetric
*/ */
appName?: string; appName?: string;
/**
*
* @type {string}
* @memberof RequestsPerSecondSchemaDataResultInnerMetric
*/
endpoint?: string;
} }
/** /**

View File

@ -0,0 +1,2 @@
export const unknownify = (value?: string) =>
!value || value === 'undefined' ? 'unknown' : value;

View File

@ -28,6 +28,9 @@ export const requestsPerSecondSchema = {
appName: { appName: {
type: 'string', type: 'string',
}, },
endpoint: {
type: 'string',
},
}, },
}, },
values: { values: {

View File

@ -12,10 +12,9 @@ async function getSetup() {
preRouterHook: perms.hook, preRouterHook: perms.hook,
}); });
const services = createServices(stores, config); const services = createServices(stores, config);
jest.spyOn( jest.spyOn(services.clientInstanceService, 'getRPS').mockImplementation(
services.clientInstanceService, async () => {},
'getRPSForPath', );
).mockImplementation(async () => jest.fn());
const app = await getApp(config, stores, services); const app = await getApp(config, stores, services);
return { return {

View File

@ -187,26 +187,8 @@ class MetricsController extends Controller {
} }
try { try {
const hoursToQuery = 6; const hoursToQuery = 6;
const [clientMetrics, frontendMetrics, adminMetrics] = const rps = await this.clientInstanceService.getRPS(hoursToQuery);
await Promise.all([ res.json(rps || {});
this.clientInstanceService.getRPSForPath(
'/api/client/.*',
hoursToQuery,
),
this.clientInstanceService.getRPSForPath(
'/api/frontend.*',
hoursToQuery,
),
this.clientInstanceService.getRPSForPath(
'/api/admin/.*',
hoursToQuery,
),
]);
res.json({
clientMetrics,
frontendMetrics,
adminMetrics,
});
} catch (err) { } catch (err) {
this.logger.error('Failed to fetch RPS metrics', err); this.logger.error('Failed to fetch RPS metrics', err);
res.status(500).send('Error fetching RPS metrics'); res.status(500).send('Error fetching RPS metrics');

View File

@ -224,16 +224,17 @@ export default class ClientInstanceService {
return (d.getTime() - d.getMilliseconds()) / 1000; return (d.getTime() - d.getMilliseconds()) / 1000;
} }
async getRPSForPath(path: string, hoursToQuery: number): Promise<any> { async getRPS(hoursToQuery: number): Promise<any> {
if (!this.prometheusApi) { if (!this.prometheusApi) {
this.logger.warn('Prometheus not configured'); this.logger.warn('Prometheus not configured');
return; return;
} }
const timeoutSeconds = 5; const timeoutSeconds = 5;
const basePath = this.serverOption.baseUriPath; const basePath = this.serverOption.baseUriPath.replace(/\/$/, '');
const compositePath = `${basePath}/${path}`.replaceAll('//', '/'); const pathQuery = `${basePath}/api/.*`;
const step = '5m'; // validate: I'm using the step both for step in query_range and for irate const step = '5m';
const query = `sum by(appName) (irate (http_request_duration_milliseconds_count{path=~"${compositePath}"} [${step}]))`; const rpsQuery = `irate (http_request_duration_milliseconds_count{path=~"${pathQuery}"} [${step}])`;
const query = `sum by(appName, endpoint) (label_replace(${rpsQuery}, "endpoint", "$1", "path", "${basePath}(/api/(?:client/)?[^/\*]*).*"))`;
const end = new Date(); const end = new Date();
const start = new Date(); const start = new Date();
start.setHours(end.getHours() - hoursToQuery); start.setHours(end.getHours() - hoursToQuery);

View File

@ -2739,6 +2739,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"appName": { "appName": {
"type": "string", "type": "string",
}, },
"endpoint": {
"type": "string",
},
}, },
"type": "object", "type": "object",
}, },