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:
parent
2c15841af4
commit
4b519ead4f
@ -3,6 +3,7 @@ import { Mermaid } from 'component/common/Mermaid/Mermaid';
|
||||
import { useInstanceMetrics } from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Alert, styled } from '@mui/material';
|
||||
import { unknownify } from 'utils/unknownify';
|
||||
|
||||
const StyledMermaid = styled(Mermaid)(({ theme }) => ({
|
||||
'#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 {
|
||||
label?: string;
|
||||
reqs: string;
|
||||
@ -20,26 +28,33 @@ interface INetworkApp {
|
||||
export const NetworkOverview = () => {
|
||||
usePageTitle('Network - Overview');
|
||||
const { metrics } = useInstanceMetrics();
|
||||
const results = metrics?.data?.result;
|
||||
|
||||
const apps: INetworkApp[] = [];
|
||||
if (Boolean(metrics)) {
|
||||
Object.keys(metrics).forEach(metric => {
|
||||
apps.push(
|
||||
...(
|
||||
metrics[metric].data?.result
|
||||
?.map(result => ({
|
||||
label: result.metric?.appName,
|
||||
reqs: parseFloat(
|
||||
result.values?.[
|
||||
result.values?.length - 1
|
||||
][1].toString() || '0'
|
||||
).toFixed(2),
|
||||
type: metric.split('Metrics')[0],
|
||||
}))
|
||||
.filter(app => app.label !== 'undefined') || []
|
||||
).filter(app => app.reqs !== '0.00')
|
||||
);
|
||||
});
|
||||
|
||||
if (results) {
|
||||
apps.push(
|
||||
...(
|
||||
results
|
||||
?.map(result => {
|
||||
const values = (result.values || []) as ResultValue[];
|
||||
const data =
|
||||
values.filter(value => isRecent(value)) || [];
|
||||
let reqs = 0;
|
||||
if (data.length) {
|
||||
reqs = parseFloat(data[data.length - 1][1]);
|
||||
}
|
||||
return {
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
const graph = `
|
||||
|
@ -1,13 +1,9 @@
|
||||
import {
|
||||
InstanceMetrics,
|
||||
useInstanceMetrics,
|
||||
} from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
|
||||
import { useInstanceMetrics } from 'hooks/api/getters/useInstanceMetrics/useInstanceMetrics';
|
||||
import { useMemo, VFC } from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
ChartData,
|
||||
ChartDataset,
|
||||
ChartOptions,
|
||||
Legend,
|
||||
@ -26,11 +22,12 @@ import theme from 'themes/theme';
|
||||
import { formatDateHM } from 'utils/formatDate';
|
||||
import { RequestsPerSecondSchema } from 'openapi';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { Alert, PaletteColor } from '@mui/material';
|
||||
import { Alert } from '@mui/material';
|
||||
import { Box } from '@mui/system';
|
||||
import { CyclicIterator } from 'utils/cyclicIterator';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { unknownify } from 'utils/unknownify';
|
||||
|
||||
interface IPoint {
|
||||
x: number;
|
||||
@ -38,150 +35,150 @@ interface IPoint {
|
||||
}
|
||||
|
||||
type ChartDatasetType = ChartDataset<'line', IPoint[]>;
|
||||
type ChartDataType = ChartData<'line', IPoint[], string>;
|
||||
|
||||
type ResultValue = [number, string];
|
||||
|
||||
const createChartPoints = (
|
||||
values: Array<Array<number | string>>,
|
||||
values: ResultValue[],
|
||||
y: (m: string) => number
|
||||
): IPoint[] => {
|
||||
return values.map(row => ({
|
||||
x: row[0] as number,
|
||||
y: y(row[1] as string),
|
||||
x: row[0],
|
||||
y: y(row[1]),
|
||||
}));
|
||||
};
|
||||
|
||||
const createInstanceChartOptions = (
|
||||
metrics: InstanceMetrics,
|
||||
locationSettings: ILocationSettings
|
||||
): ChartOptions<'line'> => {
|
||||
return {
|
||||
locale: locationSettings.locale,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
): ChartOptions<'line'> => ({
|
||||
locale: locationSettings.locale,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
bodyColor: theme.palette.text.primary,
|
||||
titleColor: theme.palette.grey[700],
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
boxPadding: 5,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
title: items =>
|
||||
formatDateHM(
|
||||
1000 * items[0].parsed.x,
|
||||
locationSettings.locale
|
||||
),
|
||||
},
|
||||
itemSort: (a, b) => b.parsed.y - a.parsed.y,
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
bodyColor: theme.palette.text.primary,
|
||||
titleColor: theme.palette.grey[700],
|
||||
borderColor: theme.palette.primary.main,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
boxPadding: 5,
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
callbacks: {
|
||||
title: items =>
|
||||
formatDateHM(
|
||||
1000 * items[0].parsed.x,
|
||||
locationSettings.locale
|
||||
),
|
||||
},
|
||||
itemSort: (a, b) => b.parsed.y - a.parsed.y,
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: 'Requests per second in the last 6 hours',
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
display: true,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: '400',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
text: 'Requests per second in the last 6 hours',
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
display: true,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: '400',
|
||||
},
|
||||
text: 'Requests per second',
|
||||
},
|
||||
// min: 0,
|
||||
suggestedMin: 0,
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
x: {
|
||||
type: 'time',
|
||||
time: { unit: 'minute' },
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
callback: (_, i, data) =>
|
||||
formatDateHM(data[i].value, locationSettings.locale),
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Requests per second',
|
||||
},
|
||||
// min: 0,
|
||||
suggestedMin: 0,
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
x: {
|
||||
type: 'time',
|
||||
time: { unit: 'minute' },
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
callback: (_, i, data) =>
|
||||
formatDateHM(data[i].value, locationSettings.locale),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const toChartData = (
|
||||
rps: RequestsPerSecondSchema,
|
||||
color: PaletteColor,
|
||||
label: (name: string) => string
|
||||
): ChartDatasetType[] => {
|
||||
if (rps.data?.result) {
|
||||
return rps.data.result.map(dataset => ({
|
||||
label: label(dataset.metric?.appName || 'unknown'),
|
||||
borderColor: color.main,
|
||||
backgroundColor: color.main,
|
||||
data: createChartPoints(dataset.values || [], y => parseFloat(y)),
|
||||
elements: {
|
||||
point: {
|
||||
radius: 4,
|
||||
pointStyle: 'circle',
|
||||
},
|
||||
line: {
|
||||
borderDash: [8, 4],
|
||||
},
|
||||
},
|
||||
}));
|
||||
class ItemPicker<T> {
|
||||
private items: CyclicIterator<T>;
|
||||
private picked: Map<string, T> = new Map();
|
||||
constructor(items: T[]) {
|
||||
this.items = new CyclicIterator<T>(items);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const createInstanceChartData = (metrics?: InstanceMetrics): ChartDataType => {
|
||||
if (metrics) {
|
||||
const colors = new CyclicIterator<PaletteColor>([
|
||||
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,
|
||||
]);
|
||||
let datasets: ChartDatasetType[] = [];
|
||||
for (let key in metrics) {
|
||||
datasets = datasets.concat(
|
||||
toChartData(
|
||||
metrics[key],
|
||||
colors.next(),
|
||||
metricName => `${metricName}: ${key}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return { datasets };
|
||||
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,
|
||||
backgroundColor: color.main,
|
||||
data: createChartPoints(values, y => parseFloat(y)),
|
||||
elements: {
|
||||
point: {
|
||||
radius: 4,
|
||||
pointStyle: 'circle',
|
||||
},
|
||||
line: {
|
||||
borderDash: [8, 4],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return { datasets: [] };
|
||||
return [];
|
||||
};
|
||||
|
||||
export const NetworkTraffic: VFC = () => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
const { metrics } = useInstanceMetrics();
|
||||
const options = useMemo(() => {
|
||||
return createInstanceChartOptions(metrics, locationSettings);
|
||||
}, [metrics, locationSettings]);
|
||||
return createInstanceChartOptions(locationSettings);
|
||||
}, [locationSettings]);
|
||||
|
||||
usePageTitle('Network - Traffic');
|
||||
|
||||
const data = useMemo(() => {
|
||||
return createInstanceChartData(metrics);
|
||||
return { datasets: toChartData(metrics) };
|
||||
}, [metrics, locationSettings]);
|
||||
|
||||
return (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { styled } from '@mui/material';
|
||||
import mermaid from 'mermaid';
|
||||
import { useEffect } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
const StyledMermaid = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -11,6 +11,7 @@ const StyledMermaid = styled('div')(({ theme }) => ({
|
||||
}));
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
themeCSS: `
|
||||
.clusters #_ rect {
|
||||
@ -21,23 +22,19 @@ mermaid.initialize({
|
||||
});
|
||||
|
||||
interface IMermaidProps {
|
||||
className?: string;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export const Mermaid = ({ className = '', children }: IMermaidProps) => {
|
||||
export const Mermaid = ({ children, ...props }: IMermaidProps) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.render('mermaid', children, (svgCode: string) => {
|
||||
const mermaidDiv = document.querySelector('.mermaid');
|
||||
if (mermaidDiv) {
|
||||
mermaidDiv.innerHTML = svgCode;
|
||||
mermaid.render('mermaid', children, svgCode => {
|
||||
if (mermaidRef.current) {
|
||||
mermaidRef.current.innerHTML = svgCode;
|
||||
}
|
||||
});
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<StyledMermaid className={`mermaid ${className}`}>
|
||||
{children}
|
||||
</StyledMermaid>
|
||||
);
|
||||
return <StyledMermaid ref={mermaidRef} {...props} />;
|
||||
};
|
||||
|
@ -4,12 +4,8 @@ import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
import { RequestsPerSecondSchema } from 'openapi';
|
||||
|
||||
export interface InstanceMetrics {
|
||||
[key: string]: RequestsPerSecondSchema;
|
||||
}
|
||||
|
||||
export interface IInstanceMetricsResponse {
|
||||
metrics: InstanceMetrics;
|
||||
metrics: RequestsPerSecondSchema;
|
||||
|
||||
refetch: () => void;
|
||||
|
||||
|
@ -25,6 +25,12 @@ export interface RequestsPerSecondSchemaDataResultInnerMetric {
|
||||
* @memberof RequestsPerSecondSchemaDataResultInnerMetric
|
||||
*/
|
||||
appName?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof RequestsPerSecondSchemaDataResultInnerMetric
|
||||
*/
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
2
frontend/src/utils/unknownify.ts
Normal file
2
frontend/src/utils/unknownify.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const unknownify = (value?: string) =>
|
||||
!value || value === 'undefined' ? 'unknown' : value;
|
@ -28,6 +28,9 @@ export const requestsPerSecondSchema = {
|
||||
appName: {
|
||||
type: 'string',
|
||||
},
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
values: {
|
||||
|
@ -12,10 +12,9 @@ async function getSetup() {
|
||||
preRouterHook: perms.hook,
|
||||
});
|
||||
const services = createServices(stores, config);
|
||||
jest.spyOn(
|
||||
services.clientInstanceService,
|
||||
'getRPSForPath',
|
||||
).mockImplementation(async () => jest.fn());
|
||||
jest.spyOn(services.clientInstanceService, 'getRPS').mockImplementation(
|
||||
async () => {},
|
||||
);
|
||||
const app = await getApp(config, stores, services);
|
||||
|
||||
return {
|
||||
|
@ -187,26 +187,8 @@ class MetricsController extends Controller {
|
||||
}
|
||||
try {
|
||||
const hoursToQuery = 6;
|
||||
const [clientMetrics, frontendMetrics, adminMetrics] =
|
||||
await Promise.all([
|
||||
this.clientInstanceService.getRPSForPath(
|
||||
'/api/client/.*',
|
||||
hoursToQuery,
|
||||
),
|
||||
this.clientInstanceService.getRPSForPath(
|
||||
'/api/frontend.*',
|
||||
hoursToQuery,
|
||||
),
|
||||
this.clientInstanceService.getRPSForPath(
|
||||
'/api/admin/.*',
|
||||
hoursToQuery,
|
||||
),
|
||||
]);
|
||||
res.json({
|
||||
clientMetrics,
|
||||
frontendMetrics,
|
||||
adminMetrics,
|
||||
});
|
||||
const rps = await this.clientInstanceService.getRPS(hoursToQuery);
|
||||
res.json(rps || {});
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to fetch RPS metrics', err);
|
||||
res.status(500).send('Error fetching RPS metrics');
|
||||
|
@ -224,16 +224,17 @@ export default class ClientInstanceService {
|
||||
return (d.getTime() - d.getMilliseconds()) / 1000;
|
||||
}
|
||||
|
||||
async getRPSForPath(path: string, hoursToQuery: number): Promise<any> {
|
||||
async getRPS(hoursToQuery: number): Promise<any> {
|
||||
if (!this.prometheusApi) {
|
||||
this.logger.warn('Prometheus not configured');
|
||||
return;
|
||||
}
|
||||
const timeoutSeconds = 5;
|
||||
const basePath = this.serverOption.baseUriPath;
|
||||
const compositePath = `${basePath}/${path}`.replaceAll('//', '/');
|
||||
const step = '5m'; // validate: I'm using the step both for step in query_range and for irate
|
||||
const query = `sum by(appName) (irate (http_request_duration_milliseconds_count{path=~"${compositePath}"} [${step}]))`;
|
||||
const basePath = this.serverOption.baseUriPath.replace(/\/$/, '');
|
||||
const pathQuery = `${basePath}/api/.*`;
|
||||
const step = '5m';
|
||||
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 start = new Date();
|
||||
start.setHours(end.getHours() - hoursToQuery);
|
||||
|
@ -2739,6 +2739,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"appName": {
|
||||
"type": "string",
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user