Prometheus: exemplars show different symbols (#34763) (#34899)

* Show different symbols for different queries

* Only run different exemplars

* Address review comment

* Do the same for dashboard + tests

(cherry picked from commit 4435895833)

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
Grot (@grafanabot) 2021-05-28 10:05:33 -04:00 committed by GitHub
parent 6d6e875a84
commit f704fa423d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 239 additions and 59 deletions

View File

@ -1,4 +1,4 @@
import { DataFrame } from '@grafana/data';
import { DataFrame, DataFrameFieldIndex } from '@grafana/data';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import { usePlotContext } from '../context';
import { Marker } from './Marker';
@ -9,8 +9,11 @@ interface EventsCanvasProps {
id: string;
config: UPlotConfigBuilder;
events: DataFrame[];
renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined;
renderEventMarker: (dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => React.ReactNode;
mapEventToXYCoords: (
dataFrame: DataFrame,
dataFrameFieldIndex: DataFrameFieldIndex
) => { x: number; y: number } | undefined;
}
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
@ -35,13 +38,13 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords
for (let i = 0; i < events.length; i++) {
const frame = events[i];
for (let j = 0; j < frame.length; j++) {
const coords = mapEventToXYCoords(frame, j);
const coords = mapEventToXYCoords(frame, { fieldIndex: j, frameIndex: i });
if (!coords) {
continue;
}
markers.push(
<Marker {...coords} key={`${id}-marker-${i}-${j}`}>
{renderEventMarker(frame, j)}
{renderEventMarker(frame, { fieldIndex: j, frameIndex: i })}
</Marker>
);
}

View File

@ -1759,7 +1759,17 @@ describe('PrometheusDatasource for POST', () => {
});
});
const getPrepareTargetsContext = (target: PromQuery, app?: CoreApp, queryOptions?: Partial<QueryOptions>) => {
function getPrepareTargetsContext({
targets,
app,
queryOptions,
languageProvider,
}: {
targets: PromQuery[];
app?: CoreApp;
queryOptions?: Partial<QueryOptions>;
languageProvider?: any;
}) {
const instanceSettings = ({
url: 'proxied',
directUrl: 'direct',
@ -1771,7 +1781,7 @@ const getPrepareTargetsContext = (target: PromQuery, app?: CoreApp, queryOptions
const end = 1;
const panelId = '2';
const options = ({
targets: [target],
targets,
interval: '1s',
panelId,
app,
@ -1779,6 +1789,9 @@ const getPrepareTargetsContext = (target: PromQuery, app?: CoreApp, queryOptions
} as any) as DataQueryRequest<PromQuery>;
const ds = new PrometheusDatasource(instanceSettings, templateSrvStub as any, timeSrvStub as any);
if (languageProvider) {
ds.languageProvider = languageProvider;
}
const { queries, activeTargets } = ds.prepareTargets(options, start, end);
return {
@ -1788,7 +1801,7 @@ const getPrepareTargetsContext = (target: PromQuery, app?: CoreApp, queryOptions
end,
panelId,
};
};
}
describe('prepareTargets', () => {
describe('when run from a Panel', () => {
@ -1799,7 +1812,7 @@ describe('prepareTargets', () => {
requestId: '2A',
};
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target);
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext({ targets: [target] });
expect(queries.length).toBe(1);
expect(activeTargets.length).toBe(1);
@ -1819,6 +1832,51 @@ describe('prepareTargets', () => {
});
expect(activeTargets[0]).toEqual(target);
});
it('should give back 3 targets when multiple queries with exemplar enabled and same metric', () => {
const targetA: PromQuery = {
refId: 'A',
expr: 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
exemplar: true,
};
const targetB: PromQuery = {
refId: 'B',
expr: 'histogram_quantile(0.5, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
exemplar: true,
};
const { queries, activeTargets } = getPrepareTargetsContext({
targets: [targetA, targetB],
languageProvider: {
histogramMetrics: ['tns_request_duration_seconds_bucket'],
},
});
expect(queries).toHaveLength(3);
expect(activeTargets).toHaveLength(3);
});
it('should give back 4 targets when multiple queries with exemplar enabled', () => {
const targetA: PromQuery = {
refId: 'A',
expr: 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
exemplar: true,
};
const targetB: PromQuery = {
refId: 'B',
expr: 'histogram_quantile(0.5, sum(rate(tns_request_duration_bucket[5m])) by (le))',
exemplar: true,
};
const { queries, activeTargets } = getPrepareTargetsContext({
targets: [targetA, targetB],
languageProvider: {
histogramMetrics: ['tns_request_duration_seconds_bucket'],
},
});
expect(queries).toHaveLength(4);
expect(activeTargets).toHaveLength(4);
});
it('should give back 2 targets when exemplar enabled', () => {
const target: PromQuery = {
refId: 'A',
@ -1826,7 +1884,7 @@ describe('prepareTargets', () => {
exemplar: true,
};
const { queries, activeTargets } = getPrepareTargetsContext(target);
const { queries, activeTargets } = getPrepareTargetsContext({ targets: [target] });
expect(queries).toHaveLength(2);
expect(activeTargets).toHaveLength(2);
expect(activeTargets[0].exemplar).toBe(true);
@ -1840,7 +1898,7 @@ describe('prepareTargets', () => {
instant: true,
};
const { queries, activeTargets } = getPrepareTargetsContext(target);
const { queries, activeTargets } = getPrepareTargetsContext({ targets: [target] });
expect(queries).toHaveLength(1);
expect(activeTargets).toHaveLength(1);
expect(activeTargets[0].instant).toBe(true);
@ -1849,6 +1907,60 @@ describe('prepareTargets', () => {
describe('when run from Explore', () => {
describe('when query type Both is selected', () => {
it('should give back 6 targets when multiple queries with exemplar enabled', () => {
const targetA: PromQuery = {
refId: 'A',
expr: 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
instant: true,
range: true,
exemplar: true,
};
const targetB: PromQuery = {
refId: 'B',
expr: 'histogram_quantile(0.5, sum(rate(tns_request_duration_bucket[5m])) by (le))',
exemplar: true,
instant: true,
range: true,
};
const { queries, activeTargets } = getPrepareTargetsContext({
targets: [targetA, targetB],
app: CoreApp.Explore,
languageProvider: {
histogramMetrics: ['tns_request_duration_seconds_bucket'],
},
});
expect(queries).toHaveLength(6);
expect(activeTargets).toHaveLength(6);
});
it('should give back 5 targets when multiple queries with exemplar enabled and same metric', () => {
const targetA: PromQuery = {
refId: 'A',
expr: 'histogram_quantile(0.95, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
instant: true,
range: true,
exemplar: true,
};
const targetB: PromQuery = {
refId: 'B',
expr: 'histogram_quantile(0.5, sum(rate(tns_request_duration_seconds_bucket[5m])) by (le))',
exemplar: true,
instant: true,
range: true,
};
const { queries, activeTargets } = getPrepareTargetsContext({
targets: [targetA, targetB],
app: CoreApp.Explore,
languageProvider: {
histogramMetrics: ['tns_request_duration_seconds_bucket'],
},
});
expect(queries).toHaveLength(5);
expect(activeTargets).toHaveLength(5);
});
it('then it should return both instant and time series related objects', () => {
const target: PromQuery = {
refId: 'A',
@ -1858,7 +1970,10 @@ describe('prepareTargets', () => {
requestId: '2A',
};
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target, CoreApp.Explore);
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext({
targets: [target],
app: CoreApp.Explore,
});
expect(queries.length).toBe(2);
expect(activeTargets.length).toBe(2);
@ -1916,7 +2031,10 @@ describe('prepareTargets', () => {
requestId: '2A',
};
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target, CoreApp.Explore);
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext({
targets: [target],
app: CoreApp.Explore,
});
expect(queries.length).toBe(1);
expect(activeTargets.length).toBe(1);
@ -1949,7 +2067,10 @@ describe('prepareTargets', () => {
requestId: '2A',
};
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext(target, CoreApp.Explore);
const { queries, activeTargets, panelId, end, start } = getPrepareTargetsContext({
targets: [target],
app: CoreApp.Explore,
});
expect(queries.length).toBe(1);
expect(activeTargets.length).toBe(1);

View File

@ -206,6 +206,7 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
}
target.requestId = options.panelId + target.refId;
const metricName = this.languageProvider.histogramMetrics.find((m) => target.expr.includes(m));
// In Explore, we run both (instant and range) queries if both are true (selected) or both are undefined (legacy Explore queries)
if (options.app === CoreApp.Explore && target.range === target.instant) {
@ -226,14 +227,20 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
// Create exemplar query
if (target.exemplar) {
// Only create exemplar target for different metric names
if (
!metricName ||
(metricName && !activeTargets.some((activeTarget) => activeTarget.expr.includes(metricName)))
) {
const exemplarTarget = cloneDeep(target);
exemplarTarget.instant = false;
exemplarTarget.requestId += '_exemplar';
instantTarget.exemplar = false;
rangeTarget.exemplar = false;
queries.push(this.createQuery(exemplarTarget, options, start, end));
activeTargets.push(exemplarTarget);
}
instantTarget.exemplar = false;
rangeTarget.exemplar = false;
}
// Add both targets to activeTargets and queries arrays
activeTargets.push(instantTarget, rangeTarget);
@ -250,13 +257,18 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
} else {
// It doesn't make sense to query for exemplars in dashboard if only instant is selected
if (target.exemplar && !target.instant) {
if (
!metricName ||
(metricName && !activeTargets.some((activeTarget) => activeTarget.expr.includes(metricName)))
) {
const exemplarTarget = cloneDeep(target);
exemplarTarget.requestId += '_exemplar';
target.exemplar = false;
queries.push(this.createQuery(exemplarTarget, options, start, end));
activeTargets.push(exemplarTarget);
this.exemplarErrors.next();
}
target.exemplar = false;
}
if (target.exemplar && target.instant) {
this.exemplarErrors.next('Exemplars are not available for instant queries.');
}

View File

@ -1,4 +1,4 @@
import { DataFrame, DataFrameView, getColorForTheme, TimeZone } from '@grafana/data';
import { DataFrame, DataFrameFieldIndex, DataFrameView, getColorForTheme, TimeZone } from '@grafana/data';
import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { AnnotationMarker } from './AnnotationMarker';
@ -65,9 +65,9 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
}, [config, theme]);
const mapAnnotationToXYCoords = useCallback(
(frame: DataFrame, index: number) => {
(frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
const plotInstance = plotCtx.plot;
if (!annotation.time || !plotInstance) {
return undefined;
@ -82,9 +82,9 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
);
const renderMarker = useCallback(
(frame: DataFrame, index: number) => {
(frame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
const annotation = view.get(dataFrameFieldIndex.fieldIndex);
return <AnnotationMarker annotation={annotation} timeZone={timeZone} />;
},
[timeZone]

View File

@ -1,6 +1,7 @@
import { css, cx } from '@emotion/css';
import {
DataFrame,
DataFrameFieldIndex,
dateTimeFormat,
Field,
FieldType,
@ -10,18 +11,25 @@ import {
TimeZone,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { FieldLinkList, Portal, useStyles } from '@grafana/ui';
import { FieldLinkList, Portal, UPlotConfigBuilder, useStyles } from '@grafana/ui';
import React, { useCallback, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
interface ExemplarMarkerProps {
timeZone: TimeZone;
dataFrame: DataFrame;
index: number;
dataFrameFieldIndex: DataFrameFieldIndex;
config: UPlotConfigBuilder;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
}
export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFrame, index, getFieldLinks }) => {
export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({
timeZone,
dataFrame,
dataFrameFieldIndex,
config,
getFieldLinks,
}) => {
const styles = useStyles(getExemplarMarkerStyles);
const [isOpen, setIsOpen] = useState(false);
const [markerElement, setMarkerElement] = React.useState<HTMLDivElement | null>(null);
@ -29,6 +37,24 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFr
const { styles: popperStyles, attributes } = usePopper(markerElement, popperElement);
const popoverRenderTimeout = useRef<NodeJS.Timer>();
const getSymbol = () => {
const symbols = [
<rect key="diamond" x="3.38672" width="4.78985" height="4.78985" transform="rotate(45 3.38672 0)" />,
<path
key="x"
d="M1.94444 3.49988L0 5.44432L1.55552 6.99984L3.49996 5.05539L5.4444 6.99983L6.99992 5.44431L5.05548 3.49988L6.99983 1.55552L5.44431 0L3.49996 1.94436L1.5556 0L8.42584e-05 1.55552L1.94444 3.49988Z"
/>,
<path key="triangle" d="M4 0L7.4641 6H0.535898L4 0Z" />,
<rect key="rectangle" width="5" height="5" />,
<path key="pentagon" d="M3 0.5L5.85317 2.57295L4.76336 5.92705H1.23664L0.146831 2.57295L3 0.5Z" />,
<path
key="plus"
d="m2.35672,4.2425l0,2.357l1.88558,0l0,-2.357l2.3572,0l0,-1.88558l-2.3572,0l0,-2.35692l-1.88558,0l0,2.35692l-2.35672,0l0,1.88558l2.35672,0z"
/>,
];
return symbols[dataFrameFieldIndex.frameIndex % symbols.length];
};
const onMouseEnter = useCallback(() => {
if (popoverRenderTimeout.current) {
clearTimeout(popoverRenderTimeout.current);
@ -68,8 +94,10 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFr
<table className={styles.exemplarsTable}>
<tbody>
{dataFrame.fields.map((field, i) => {
const value = field.values.get(index);
const links = field.config.links?.length ? getFieldLinks(field, index) : undefined;
const value = field.values.get(dataFrameFieldIndex.fieldIndex);
const links = field.config.links?.length
? getFieldLinks(field, dataFrameFieldIndex.fieldIndex)
: undefined;
return (
<tr key={i}>
<td valign="top">{field.name}</td>
@ -93,7 +121,7 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFr
attributes.popper,
dataFrame.fields,
getFieldLinks,
index,
dataFrameFieldIndex,
onMouseEnter,
onMouseLeave,
popperStyles.popper,
@ -101,6 +129,10 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFr
timeZone,
]);
const seriesColor = config
.getSeries()
.find((s) => s.props.dataFrameFieldIndex?.frameIndex === dataFrameFieldIndex.frameIndex)?.props.lineColor;
return (
<>
<div
@ -110,8 +142,14 @@ export const ExemplarMarker: React.FC<ExemplarMarkerProps> = ({ timeZone, dataFr
className={styles.markerWrapper}
aria-label={selectors.components.DataSource.Prometheus.exemplarMarker}
>
<svg viewBox="0 0 599 599" width="8" height="8" className={cx(styles.marble, isOpen && styles.activeMarble)}>
<path d="M 300,575 L 575,300 L 300,25 L 25,300 L 300,575 Z" />
<svg
viewBox="0 0 7 7"
width="7"
height="7"
style={{ fill: seriesColor }}
className={cx(styles.marble, isOpen && styles.activeMarble)}
>
{getSymbol()}
</svg>
</div>
{isOpen && <Portal>{renderMarker()}</Portal>}
@ -123,21 +161,8 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
const bg = theme.isDark ? theme.palette.dark2 : theme.palette.white;
const headerBg = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
const shadowColor = theme.isDark ? theme.palette.black : theme.palette.white;
const marbleFill = theme.isDark ? theme.palette.gray3 : theme.palette.gray1;
const marbleFillHover = theme.isDark ? theme.palette.blue85 : theme.palette.blue77;
const tableBgOdd = theme.isDark ? theme.palette.dark3 : theme.palette.gray6;
const marble = css`
display: block;
fill: ${marbleFill};
transition: transform 0.15s ease-out;
`;
const activeMarble = css`
fill: ${marbleFillHover};
transform: scale(1.3);
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5));
`;
return {
markerWrapper: css`
padding: 0 4px 4px 4px;
@ -147,7 +172,9 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
&:hover {
> svg {
${activeMarble}
transform: scale(1.3);
opacity: 1;
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5));
}
}
`,
@ -218,7 +245,15 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme) => {
padding: ${theme.spacing.sm};
font-weight: ${theme.typography.weight.semibold};
`,
marble,
activeMarble,
marble: css`
display: block;
opacity: 0.5;
transition: transform 0.15s ease-out;
`,
activeMarble: css`
transform: scale(1.3);
opacity: 1;
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.5));
`,
};
};

View File

@ -1,5 +1,6 @@
import {
DataFrame,
DataFrameFieldIndex,
Field,
LinkModel,
TimeZone,
@ -21,7 +22,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
const plotCtx = usePlotContext();
const mapExemplarToXYCoords = useCallback(
(dataFrame: DataFrame, index: number) => {
(dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
const plotInstance = plotCtx.plot;
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
@ -37,7 +38,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
const yMin = plotInstance.scales[yScale].min;
const yMax = plotInstance.scales[yScale].max;
let y = value.values.get(index);
let y = value.values.get(dataFrameFieldIndex.fieldIndex);
// To not to show exemplars outside of the graph we set the y value to min if it is smaller and max if it is bigger than the size of the graph
if (yMin != null && y < yMin) {
y = yMin;
@ -48,7 +49,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
}
return {
x: plotInstance.valToPos(time.values.get(index), 'x'),
x: plotInstance.valToPos(time.values.get(dataFrameFieldIndex.fieldIndex), 'x'),
y: plotInstance.valToPos(y, yScale),
};
},
@ -56,10 +57,18 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
);
const renderMarker = useCallback(
(dataFrame: DataFrame, index: number) => {
return <ExemplarMarker timeZone={timeZone} getFieldLinks={getFieldLinks} dataFrame={dataFrame} index={index} />;
(dataFrame: DataFrame, dataFrameFieldIndex: DataFrameFieldIndex) => {
return (
<ExemplarMarker
timeZone={timeZone}
getFieldLinks={getFieldLinks}
dataFrame={dataFrame}
dataFrameFieldIndex={dataFrameFieldIndex}
config={config}
/>
);
},
[timeZone, getFieldLinks]
[config, timeZone, getFieldLinks]
);
return (