import type {MRI} from 'sentry/types/metrics'; import {getDefaultAggregate} from 'sentry/utils/metrics'; import { DEFAULT_SORT_STATE, emptyMetricsQueryWidget, NO_QUERY_ID, } from 'sentry/utils/metrics/constants'; import {isMRI} from 'sentry/utils/metrics/mri'; import { type BaseWidgetParams, type FocusedMetricsSeries, MetricChartOverlayType, MetricDisplayType, MetricExpressionType, type MetricsEquationWidget, type MetricsQueryWidget, type MetricsWidget, type SortState, } from 'sentry/utils/metrics/types'; import {getUniqueQueryIdGenerator} from 'sentry/views/metrics/utils/uniqueQueryId'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isMetricDisplayType(value: unknown): value is MetricDisplayType { return Object.values(MetricDisplayType).includes(value as MetricDisplayType); } function getMRIParam(widget: Record) { return 'mri' in widget && isMRI(widget.mri) ? widget.mri : undefined; } function parseStringParam( widget: Record, key: string ): string | undefined { const value = widget[key]; return typeof value === 'string' ? value : undefined; } function parseNumberParam( widget: Record, key: string ): number | undefined { const value = widget[key]; return typeof value === 'number' && !Number.isNaN(value) ? value : undefined; } function parseBooleanParam( widget: Record, key: string ): boolean | undefined { const value = widget[key]; return typeof value === 'boolean' ? value : undefined; } function parseArrayParam>( widget: object, key: string, entryParser: (entry: unknown) => T | undefined ): T[] { if (!(key in widget)) { return []; } // allow single values instead of arrays if (!Array.isArray(widget[key])) { const entry = entryParser(widget[key]); return entry === undefined ? [] : [entry]; } return widget[key].map(entryParser).filter((entry): entry is T => entry !== undefined); } function parseFocusedSeries(series: any): FocusedMetricsSeries | undefined { if (!isRecord(series)) { return undefined; } const id = parseStringParam(series, 'id'); const groupBy = 'groupBy' in series && isRecord(series.groupBy) ? (series.groupBy as Record) : undefined; if (!id) { return undefined; } return {id, groupBy}; } function parseSortParam(widget: Record, key: string): SortState { const sort = widget[key]; if (!isRecord(sort)) { return DEFAULT_SORT_STATE; } const name = parseStringParam(sort, 'name'); const order = 'order' in sort && (sort.order === 'desc' || sort.order === 'asc') ? sort.order : DEFAULT_SORT_STATE.order; if ( name === 'name' || name === 'avg' || name === 'min' || name === 'max' || name === 'sum' || name === 'total' ) { return {name, order}; } return {name: undefined, order}; } function isValidId(n: number | undefined): n is number { return n !== undefined && Number.isInteger(n) && n >= 0; } function parseQueryType( widget: Record, key: string ): MetricExpressionType | undefined { const value = widget[key]; return typeof value === 'number' && Object.values(MetricExpressionType).includes(value) ? value : undefined; } function parseQueryWidget( widget: Record, baseWidgetParams: BaseWidgetParams ): MetricsQueryWidget | null { const mri = getMRIParam(widget); // If we cannot retrieve an MRI, there is nothing to display if (!mri) { return null; } return { mri, op: parseStringParam(widget, 'op') ?? getDefaultAggregate(mri), query: parseStringParam(widget, 'query') ?? '', groupBy: parseArrayParam(widget, 'groupBy', entry => typeof entry === 'string' ? entry : undefined ), powerUserMode: parseBooleanParam(widget, 'powerUserMode') ?? false, ...baseWidgetParams, type: MetricExpressionType.QUERY, }; } function parseFormulaWidget( widget: Record, baseWidgetParams: BaseWidgetParams ): MetricsEquationWidget | null { const formula = parseStringParam(widget, 'formula'); // If we cannot retrieve a formula, there is nothing to display if (formula === undefined) { return null; } return { formula, ...baseWidgetParams, type: MetricExpressionType.EQUATION, }; } function parseQueryId(widget: Record, key: string): number { const value = parseNumberParam(widget, key); return isValidId(value) ? value : NO_QUERY_ID; } function fillIds( entries: MetricsWidget[], indicesWithoutId: Set, usedIds: Set ): MetricsWidget[] { if (indicesWithoutId.size > 0) { const generateId = getUniqueQueryIdGenerator(usedIds); for (const index of indicesWithoutId) { const widget = entries[index]; if (!widget) { continue; } widget.id = generateId.next().value; } } return entries; } export function parseMetricWidgetsQueryParam( queryParam?: string, defaultMRI?: MRI ): MetricsWidget[] { let currentWidgets: unknown = undefined; try { currentWidgets = JSON.parse(queryParam || ''); } catch (_) { currentWidgets = []; } // It has to be an array and non-empty if (!Array.isArray(currentWidgets)) { currentWidgets = []; } const queries: MetricsQueryWidget[] = []; const usedQueryIds = new Set(); const queryIndicesWithoutId = new Set(); const formulas: MetricsEquationWidget[] = []; const usedFormulaIds = new Set(); const formulaIndicesWithoutId = new Set(); (currentWidgets as unknown[]).forEach((widget: unknown) => { if (!isRecord(widget)) { return; } const type = parseQueryType(widget, 'type') ?? MetricExpressionType.QUERY; const id = parseQueryId(widget, 'id'); if ( type === MetricExpressionType.QUERY ? usedQueryIds.has(id) : usedFormulaIds.has(id) ) { // We drop widgets with duplicate ids return; } if (id !== NO_QUERY_ID) { if (type === MetricExpressionType.QUERY) { usedQueryIds.add(id); } else { usedFormulaIds.add(id); } } const displayType = parseStringParam(widget, 'displayType'); const baseWidgetParams: BaseWidgetParams = { type, id: !isValidId(id) ? NO_QUERY_ID : id, displayType: isMetricDisplayType(displayType) ? displayType : MetricDisplayType.LINE, focusedSeries: parseArrayParam(widget, 'focusedSeries', parseFocusedSeries), sort: parseSortParam(widget, 'sort'), isHidden: parseBooleanParam(widget, 'isHidden') ?? false, overlays: widget.overlays ? parseArrayParam(widget, 'overlays', entry => entry as MetricChartOverlayType) : [MetricChartOverlayType.SAMPLES], }; switch (type) { case MetricExpressionType.QUERY: { const query = parseQueryWidget(widget, baseWidgetParams); if (!query) { break; } queries.push(query); if (query.id === NO_QUERY_ID) { queryIndicesWithoutId.add(queries.length - 1); } break; } case MetricExpressionType.EQUATION: { const formula = parseFormulaWidget(widget, baseWidgetParams); if (!formula) { break; } formulas.push(formula); if (formula.id === NO_QUERY_ID) { formulaIndicesWithoutId.add(formulas.length - 1); } break; } default: break; } }); // Iterate over the widgets without an id and assign them a unique one if (queries.length === 0) { const mri = defaultMRI || emptyMetricsQueryWidget.mri; queries.push({ ...emptyMetricsQueryWidget, mri, op: getDefaultAggregate(mri), }); } // We can reset the id if there is only one widget if (queries.length === 1) { queries[0].id = 0; } if (formulas.length === 1) { formulas[0].id = 0; } return [ ...fillIds(queries, queryIndicesWithoutId, usedQueryIds), ...fillIds(formulas, formulaIndicesWithoutId, usedFormulaIds), ]; }