Explore Metrics: Introduce augurs sorting options in breakdown view (#91189)

* refactor breakdown scene

* refactor BreakdownScene along with LayoutSwitcher

* rename

* don't pass default layout

* better type handling

* betterer

* add @bsull/augurs

* implement LabelBreakdownScene

* integrate SortByScene in LabelBreakdownScene

* move to new directory

* introduce BreakdownSearchScene

* integrate searchScene

* cleaning

* initialize @bsull/augurs

* add interaction

* use new breakdown scene

* resolve merge conflicts

* ugrade @bsull/augurs

* update import

* update css

* update tooltip text

* refine sorting

* fix unit test

* fix

* implement outlier detector

* support wasm

* jest testing fix

* localization fix

* use unknown instead of any

* update i18n

* update betterer

* fix locales

* update test

* fix tests maybe

* prettier

* chore: update jest config

* chore: create mock for @bsull/augurs (#92156)

chore: create mock for bsull/augurs

@bsull/augurs assumes it will be running as an ESM, not
a CommonJS module, so can't be loaded by Jest (specifically
because it contains a reference to import.meta.url).

This PR provides a mock implementation which gets tests passing
again.

Ideally we'd be able to load the actual @bsull/augurs module
in tests so this is just a stopgap really, until a better
solution appears.

* fix unit tests

* remove unused BreakdownScene.tsx

* set outliers as undefined if an error occurs

* Add labels

* betterer

* reset event implemented

* fix controls positioning

* update augurs

* betterer

* i18n

* conflict fixes

* update texts

---------

Co-authored-by: Matias Chomicki <matyax@gmail.com>
Co-authored-by: Ben Sully <ben.sully@grafana.com>
This commit is contained in:
ismail simsek 2024-11-05 08:34:09 +01:00 committed by GitHub
parent 297ccfc52c
commit bcdcb1f74b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 453 additions and 14 deletions

View File

@ -4362,6 +4362,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/trails/services/sorting.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/transformers/FilterByValueTransformer/FilterByValueTransformerEditor.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],

View File

@ -20,6 +20,7 @@ const esModules = [
'@msagl',
'lodash-es',
'vscode-languageserver-types',
'@bsull/augurs',
].join('|');
module.exports = {
@ -54,6 +55,7 @@ module.exports = {
'^@grafana/schema/dist/esm/(.*)$': '<rootDir>/packages/grafana-schema/src/$1',
// prevent systemjs amd extra from breaking tests.
'systemjs/dist/extras/amd': '<rootDir>/public/test/mocks/systemjsAMDExtra.ts',
'@bsull/augurs': '<rootDir>/public/test/mocks/augurs.ts',
},
// Log the test results with dynamic Loki tags. Drone CI only
reporters: ['default', ['<rootDir>/public/test/log-reporter.js', { enable: process.env.DRONE === 'true' }]],

View File

@ -245,6 +245,7 @@
"yargs": "^17.5.1"
},
"dependencies": {
"@bsull/augurs": "0.4.2",
"@emotion/css": "11.13.4",
"@emotion/react": "11.13.3",
"@fingerprintjs/fingerprintjs": "^3.4.2",

View File

@ -18,6 +18,7 @@ import { Trans } from 'app/core/internationalization';
import { getLabelValueFromDataFrame } from '../services/levels';
import { fuzzySearch } from '../services/search';
import { sortSeries } from '../services/sorting';
import { BreakdownSearchReset } from './BreakdownSearchScene';
import { findSceneObjectsByType } from './utils';
@ -33,12 +34,18 @@ type FrameIterateCallback = (frames: DataFrame[], seriesIndex: number) => void;
export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
private unfilteredChildren: SceneFlexItem[] = [];
private series: DataFrame[] = [];
private sortBy: string;
private sortedSeries: DataFrame[] = [];
private getFilter: () => string;
public constructor({ getFilter, ...state }: ByFrameRepeaterState & { getFilter: () => string }) {
public constructor({
sortBy,
getFilter,
...state
}: ByFrameRepeaterState & { sortBy: string; getFilter: () => string }) {
super(state);
this.sortBy = sortBy;
this.getFilter = getFilter;
this.addActivationHandler(() => {
@ -69,15 +76,24 @@ export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
});
}
public sort = (sortBy: string) => {
const data = sceneGraph.getData(this);
this.sortBy = sortBy;
if (data.state.data) {
this.performRepeat(data.state.data);
}
};
private performRepeat(data: PanelData) {
const newChildren: SceneFlexItem[] = [];
this.series = data.series;
const sortedSeries = sortSeries(data.series, this.sortBy);
for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) {
const layoutChild = this.state.getLayoutChild(data, this.series[seriesIndex], seriesIndex);
for (let seriesIndex = 0; seriesIndex < sortedSeries.length; seriesIndex++) {
const layoutChild = this.state.getLayoutChild(data, sortedSeries[seriesIndex], seriesIndex);
newChildren.push(layoutChild);
}
this.sortedSeries = sortedSeries;
this.unfilteredChildren = newChildren;
if (this.getFilter()) {
@ -114,8 +130,8 @@ export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
if (!data) {
return;
}
for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) {
callback(this.series, seriesIndex);
for (let seriesIndex = 0; seriesIndex < this.sortedSeries.length; seriesIndex++) {
callback(this.sortedSeries, seriesIndex);
}
};

View File

@ -1,3 +1,4 @@
import init from '@bsull/augurs';
import { css } from '@emotion/css';
import { isNumber, max, min, throttle } from 'lodash';
import { useEffect } from 'react';
@ -35,6 +36,7 @@ import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper';
import { reportExploreMetrics } from '../interactions';
import { updateOtelJoinWithGroupLeft } from '../otel/util';
import { getSortByPreference } from '../services/store';
import { ALL_VARIABLE_VALUE } from '../services/variables';
import {
MDP_METRIC_PREVIEW,
@ -50,6 +52,7 @@ import { AddToFiltersGraphAction } from './AddToFiltersGraphAction';
import { BreakdownSearchReset, BreakdownSearchScene } from './BreakdownSearchScene';
import { ByFrameRepeater } from './ByFrameRepeater';
import { LayoutSwitcher } from './LayoutSwitcher';
import { SortByScene, SortCriteriaChanged } from './SortByScene';
import { BreakdownLayoutChangeCallback, BreakdownLayoutType } from './types';
import { getLabelOptions } from './utils';
import { BreakdownAxisChangeEvent, yAxisSyncBehavior } from './yAxisSyncBehavior';
@ -59,6 +62,7 @@ const MAX_PANELS_IN_ALL_LABELS_BREAKDOWN = 60;
export interface LabelBreakdownSceneState extends SceneObjectState {
body?: LayoutSwitcher;
search: BreakdownSearchScene;
sortBy: SortByScene;
labels: Array<SelectableValue<string>>;
value?: string;
loading?: boolean;
@ -76,6 +80,7 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
super({
...state,
labels: state.labels ?? [],
sortBy: new SortByScene({ target: 'labels' }),
search: new BreakdownSearchScene('labels'),
});
@ -85,6 +90,9 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
private _query?: AutoQueryDef;
private _onActivate() {
// eslint-disable-next-line no-console
init().then(() => console.debug('Grafana ML initialized'));
const variable = this.getVariable();
variable.subscribeToState((newState, oldState) => {
@ -102,6 +110,7 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
this.state.search.clearValueFilter();
})
);
this._subs.add(this.subscribeToEvent(SortCriteriaChanged, this.handleSortByChange));
const metricScene = sceneGraph.getAncestor(this, MetricScene);
const metric = metricScene.state.metric;
@ -204,6 +213,20 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
return variable;
}
private handleSortByChange = (event: SortCriteriaChanged) => {
if (event.target !== 'labels') {
return;
}
if (this.state.body instanceof LayoutSwitcher) {
this.state.body.state.breakdownLayouts.forEach((layout) => {
if (layout instanceof ByFrameRepeater) {
layout.sort(event.sortBy);
}
});
}
reportExploreMetrics('sorting_changed', { sortBy: event.sortBy });
};
private onReferencedVariableValueChanged() {
const variable = this.getVariable();
variable.changeValueTo(ALL_VARIABLE_VALUE);
@ -291,7 +314,7 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
}
public static Component = ({ model }: SceneComponentProps<LabelBreakdownScene>) => {
const { labels, body, search, loading, value, blockingMessage } = model.useState();
const { labels, body, search, sortBy, loading, value, blockingMessage } = model.useState();
const styles = useStyles2(getStyles);
const trail = getTrailFor(model);
@ -322,9 +345,12 @@ export class LabelBreakdownScene extends SceneObjectBase<LabelBreakdownSceneStat
)}
{value !== ALL_VARIABLE_VALUE && (
<Field label="Search" className={styles.searchField}>
<search.Component model={search} />
</Field>
<>
<Field label="Search" className={styles.searchField}>
<search.Component model={search} />
</Field>
<sortBy.Component model={sortBy} />
</>
)}
{body instanceof LayoutSwitcher && (
<Field label="View">
@ -470,6 +496,7 @@ function buildNormalLayout(
return item;
}
const { sortBy } = getSortByPreference('labels', 'outliers');
const getFilter = () => searchScene.state.filter ?? '';
return new LayoutSwitcher({
@ -511,6 +538,7 @@ function buildNormalLayout(
],
}),
getLayoutChild,
sortBy,
getFilter,
}),
new ByFrameRepeater({
@ -520,6 +548,7 @@ function buildNormalLayout(
children: [],
}),
getLayoutChild,
sortBy,
getFilter,
}),
],

View File

@ -0,0 +1,109 @@
import { css } from '@emotion/css';
import { BusEventBase, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { IconButton, Select } from '@grafana/ui';
import { Field, useStyles2 } from '@grafana/ui/';
import { Trans } from '../../../core/internationalization';
import { getSortByPreference, setSortByPreference } from '../services/store';
export interface SortBySceneState extends SceneObjectState {
target: 'fields' | 'labels';
sortBy: string;
}
export class SortCriteriaChanged extends BusEventBase {
constructor(
public target: 'fields' | 'labels',
public sortBy: string
) {
super();
}
public static type = 'sort-criteria-changed';
}
export class SortByScene extends SceneObjectBase<SortBySceneState> {
public sortingOptions = [
{
label: '',
options: [
{
value: 'outliers',
label: 'Outlying Values',
description: 'Prioritizes values that show distinct behavior from others within the same label',
},
{
value: 'alphabetical',
label: 'Name [A-Z]',
description: 'Alphabetical order',
},
{
value: 'alphabetical-reversed',
label: 'Name [Z-A]',
description: 'Reversed alphabetical order',
},
],
},
];
constructor(state: Pick<SortBySceneState, 'target'>) {
const { sortBy } = getSortByPreference(state.target, 'outliers');
super({
target: state.target,
sortBy,
});
}
public onCriteriaChange = (criteria: SelectableValue<string>) => {
if (!criteria.value) {
return;
}
this.setState({ sortBy: criteria.value });
setSortByPreference(this.state.target, criteria.value);
this.publishEvent(new SortCriteriaChanged(this.state.target, criteria.value), true);
};
public static Component = ({ model }: SceneComponentProps<SortByScene>) => {
const styles = useStyles2(getStyles);
const { sortBy } = model.useState();
const group = model.sortingOptions.find((group) => group.options.find((option) => option.value === sortBy));
const value = group?.options.find((option) => option.value === sortBy);
return (
<Field
htmlFor="sort-by-criteria"
label={
<div className={styles.sortByTooltip}>
<Trans i18nKey="explore-metrics.breakdown.sortBy">Sort by</Trans>
<IconButton
name={'info-circle'}
size="sm"
variant={'secondary'}
tooltip="Sorts values using standard or smart time series calculations."
/>
</div>
}
>
<Select
value={value}
width={20}
isSearchable={true}
options={model.sortingOptions}
placeholder={'Choose criteria'}
onChange={model.onCriteriaChange}
inputId="sort-by-criteria"
/>
</Field>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
sortByTooltip: css({
display: 'flex',
gap: theme.spacing(1),
}),
};
}

View File

@ -111,6 +111,10 @@ type Interactions = {
| 'close'
)
};
sorting_changed: {
// type of sorting
sortBy: string
};
wasm_not_supported: {},
};

View File

@ -0,0 +1,74 @@
import { toDataFrame, FieldType, ReducerID } from '@grafana/data';
import { sortSeries } from './sorting';
const frameA = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [0] },
{
name: 'Value',
type: FieldType.number,
values: [0, 1, 0],
labels: {
test: 'C',
},
},
],
});
const frameB = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [0] },
{
name: 'Value',
type: FieldType.number,
values: [1, 1, 1],
labels: {
test: 'A',
},
},
],
});
const frameC = toDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [0] },
{
name: 'Value',
type: FieldType.number,
values: [100, 9999, 100],
labels: {
test: 'B',
},
},
],
});
describe('sortSeries', () => {
test('Sorts series by standard deviation, descending', () => {
const series = [frameA, frameB, frameC];
const sortedSeries = [frameC, frameA, frameB];
const result = sortSeries(series, ReducerID.stdDev, 'desc');
expect(result).toEqual(sortedSeries);
});
test('Sorts series by standard deviation, ascending', () => {
const series = [frameA, frameB, frameC];
const sortedSeries = [frameB, frameA, frameC];
const result = sortSeries(series, ReducerID.stdDev, 'asc');
expect(result).toEqual(sortedSeries);
});
test('Sorts series alphabetically, ascending', () => {
const series = [frameA, frameB, frameC];
const sortedSeries = [frameB, frameC, frameA];
const result = sortSeries(series, 'alphabetical', 'asc');
expect(result).toEqual(sortedSeries);
});
test('Sorts series alphabetically, descending', () => {
const series = [frameA, frameB, frameC];
const sortedSeries = [frameB, frameC, frameA];
const result = sortSeries(series, 'alphabetical', 'desc');
expect(result).toEqual(sortedSeries);
});
});

View File

@ -0,0 +1,138 @@
import { OutlierDetector, OutlierOutput } from '@bsull/augurs';
import { memoize } from 'lodash';
import { DataFrame, doStandardCalcs, fieldReducers, FieldType, outerJoinDataFrames, ReducerID } from '@grafana/data';
import { reportExploreMetrics } from '../interactions';
import { getLabelValueFromDataFrame } from './levels';
export const sortSeries = memoize(
(series: DataFrame[], sortBy: string, direction = 'asc') => {
if (sortBy === 'alphabetical') {
return sortSeriesByName(series, 'asc');
}
if (sortBy === 'alphabetical-reversed') {
return sortSeriesByName(series, 'desc');
}
if (sortBy === 'outliers') {
initOutlierDetector(series);
}
const reducer = (dataFrame: DataFrame) => {
try {
if (sortBy === 'outliers') {
return calculateOutlierValue(series, dataFrame);
}
} catch (e) {
console.error(e);
// ML sorting panicked, fallback to stdDev
sortBy = ReducerID.stdDev;
}
const fieldReducer = fieldReducers.get(sortBy);
const value =
fieldReducer.reduce?.(dataFrame.fields[1], true, true) ?? doStandardCalcs(dataFrame.fields[1], true, true);
return value[sortBy] ?? 0;
};
const seriesCalcs = series.map((dataFrame) => ({
value: reducer(dataFrame),
dataFrame: dataFrame,
}));
seriesCalcs.sort((a, b) => {
if (a.value !== undefined && b.value !== undefined) {
return b.value - a.value;
}
return 0;
});
if (direction === 'asc') {
seriesCalcs.reverse();
}
return seriesCalcs.map(({ dataFrame }) => dataFrame);
},
(series: DataFrame[], sortBy: string, direction = 'asc') => {
const firstTimestamp = series.length > 0 ? series[0].fields[0].values[0] : 0;
const lastTimestamp =
series.length > 0
? series[series.length - 1].fields[0].values[series[series.length - 1].fields[0].values.length - 1]
: 0;
const firstValue = series.length > 0 ? getLabelValueFromDataFrame(series[0]) : '';
const lastValue = series.length > 0 ? getLabelValueFromDataFrame(series[series.length - 1]) : '';
const key = `${firstValue}_${lastValue}_${firstTimestamp}_${lastTimestamp}_${series.length}_${sortBy}_${direction}`;
return key;
}
);
const initOutlierDetector = (series: DataFrame[]) => {
if (!wasmSupported()) {
return;
}
// Combine all frames into one by joining on time.
const joined = outerJoinDataFrames({ frames: series });
if (!joined) {
return;
}
// Get number fields: these are our series.
const joinedSeries = joined.fields.filter((f) => f.type === FieldType.number);
const nTimestamps = joinedSeries[0].values.length;
const points = new Float64Array(joinedSeries.flatMap((series) => series.values as number[]));
try {
const detector = OutlierDetector.dbscan({ sensitivity: 0.4 }).preprocess(points, nTimestamps);
outliers = detector.detect();
} catch (e) {
console.error(e);
outliers = undefined;
}
};
let outliers: OutlierOutput | undefined = undefined;
export const calculateOutlierValue = (series: DataFrame[], data: DataFrame): number => {
if (!wasmSupported()) {
throw new Error('WASM not supported, fall back to stdDev');
}
if (!outliers) {
throw new Error('Initialize outlier detector first');
}
const index = series.indexOf(data);
if (outliers.seriesResults[index].isOutlier) {
return outliers.seriesResults[index].outlierIntervals.length;
}
return 0;
};
export const sortSeriesByName = (series: DataFrame[], direction: string) => {
const sortedSeries = [...series];
sortedSeries.sort((a, b) => {
const valueA = getLabelValueFromDataFrame(a);
const valueB = getLabelValueFromDataFrame(b);
if (!valueA || !valueB) {
return 0;
}
return valueA?.localeCompare(valueB) ?? 0;
});
if (direction === 'desc') {
sortedSeries.reverse();
}
return sortedSeries;
};
export const wasmSupported = () => {
const support = typeof WebAssembly === 'object';
if (!support) {
reportExploreMetrics('wasm_not_supported', {});
}
return support;
};

View File

@ -1,4 +1,4 @@
import { TRAIL_BREAKDOWN_VIEW_KEY } from '../shared';
import { TRAIL_BREAKDOWN_VIEW_KEY, TRAIL_BREAKDOWN_SORT_KEY } from '../shared';
export function getVewByPreference() {
return localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY) ?? 'grid';
@ -7,3 +7,19 @@ export function getVewByPreference() {
export function setVewByPreference(value?: string) {
return localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, value ?? 'grid');
}
export function getSortByPreference(target: string, defaultSortBy: string) {
const preference = localStorage.getItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`) ?? '';
const parts = preference.split('.');
if (!parts[0] || !parts[1]) {
return { sortBy: defaultSortBy };
}
return { sortBy: parts[0], direction: parts[1] };
}
export function setSortByPreference(target: string, sortBy: string) {
// Prevent storing empty values
if (sortBy) {
localStorage.setItem(`${TRAIL_BREAKDOWN_SORT_KEY}.${target}.by`, `${sortBy}`);
}
}

View File

@ -43,6 +43,7 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR };
export const RECENT_TRAILS_KEY = 'grafana.trails.recent';
export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks';
export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view';
export const TRAIL_BREAKDOWN_SORT_KEY = 'grafana.trails.breakdown.sort';
export const MDP_METRIC_PREVIEW = 250;
export const MDP_METRIC_OVERVIEW = 500;

View File

@ -1049,7 +1049,8 @@
"breakdown": {
"clearFilter": "Clear filter",
"labelSelect": "Select",
"noMatchingValue": "No values found matching; {{filter}}"
"noMatchingValue": "No values found matching; {{filter}}",
"sortBy": "Sort by"
},
"viewBy": "View by"
},

View File

@ -1049,7 +1049,8 @@
"breakdown": {
"clearFilter": "Cľęäř ƒįľŧęř",
"labelSelect": "Ŝęľęčŧ",
"noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}"
"noMatchingValue": "Ńő väľūęş ƒőūʼnđ mäŧčĥįʼnģ; {{filter}}",
"sortBy": "Ŝőřŧ þy"
},
"viewBy": "Vįęŵ þy"
},

View File

@ -0,0 +1,32 @@
import type {
LoadedOutlierDetector as AugursLoadedOutlierDetector,
OutlierDetector as AugursOutlierDetector,
OutlierDetectorOptions,
OutlierOutput,
} from '@bsull/augurs';
export default function init() {}
const dummyOutliers: OutlierOutput = {
outlyingSeries: [],
clusterBand: { min: [], max: [] },
seriesResults: [],
};
export class OutlierDetector implements AugursOutlierDetector {
free(): void {}
detect(): OutlierOutput {
return dummyOutliers;
}
preprocess(y: Float64Array, nTimestamps: number): AugursLoadedOutlierDetector {
return new LoadedOutlierDetector();
}
}
export class LoadedOutlierDetector implements AugursLoadedOutlierDetector {
detect(): OutlierOutput {
return dummyOutliers;
}
free(): void {}
updateDetector(options: OutlierDetectorOptions): void {}
}

View File

@ -9,6 +9,10 @@ module.exports = {
app: './public/app/index.ts',
swagger: './public/swagger/index.tsx',
},
experiments: {
// Required to load WASM modules.
asyncWebAssembly: true,
},
output: {
clean: true,
path: path.resolve(__dirname, '../../public/build'),

View File

@ -1703,6 +1703,13 @@ __metadata:
languageName: node
linkType: hard
"@bsull/augurs@npm:0.4.2":
version: 0.4.2
resolution: "@bsull/augurs@npm:0.4.2"
checksum: 10/b4806fed1daf76380577b4ca86c2bf94c00ce1033df0b8365229a55ff16bd2f864d3f9ca3f92faf284e62013b68e953be65ee78968931ba342be0e1c054c2e63
languageName: node
linkType: hard
"@bundled-es-modules/cookie@npm:^2.0.0":
version: 2.0.0
resolution: "@bundled-es-modules/cookie@npm:2.0.0"
@ -19049,6 +19056,7 @@ __metadata:
"@betterer/betterer": "npm:5.4.0"
"@betterer/cli": "npm:5.4.0"
"@betterer/eslint": "npm:5.4.0"
"@bsull/augurs": "npm:0.4.2"
"@cypress/webpack-preprocessor": "npm:6.0.2"
"@emotion/css": "npm:11.13.4"
"@emotion/eslint-plugin": "npm:11.12.0"