import {Client} from 'sentry/api'; import {getQuerySymbol} from 'sentry/components/metrics/querySymbol'; import type {MetricMeta, MRI} from 'sentry/types/metrics'; import {convertToDashboardWidget} from 'sentry/utils/metrics/dashboard'; import type {MetricsQuery} from 'sentry/utils/metrics/types'; import {MetricDisplayType} from 'sentry/utils/metrics/types'; import type {Widget} from 'sentry/views/dashboards/types'; // import types export type ImportDashboard = { description: string; title: string; widgets: ImportWidget[]; }; export type ImportWidget = { definition: WidgetDefinition; id: number; }; type WidgetDefinition = { query: string; title: string; type: string; widgets: ImportWidget[]; legend_columns?: ('avg' | 'max' | 'min' | 'sum' | 'value')[]; requests?: Request[]; }; type Request = { display_type: 'area' | 'bars' | 'line'; formulas: Formula[]; queries: { data_source: string; name: string; query: string; }[]; response_format: 'note' | 'timeseries'; style?: { line_type: 'dotted' | 'solid'; }; }; type Formula = { formula: string; alias?: string; }; type MetricWidgetReport = { errors: string[]; id: number; outcome: ImportOutcome; title: string; }[]; type ImportOutcome = 'success' | 'warning' | 'error'; export type ParseResult = { description: string; report: MetricWidgetReport; title: string; widgets: Widget[]; }; export async function parseDashboard( dashboard: ImportDashboard, availableMetrics: MetricMeta[], orgSlug: string ): Promise { const {widgets = []} = dashboard; const flatWidgets = widgets.flatMap(widget => { if (widget.definition.type === 'group') { return widget.definition.widgets; } return [widget]; }); const results = await Promise.all( flatWidgets.map(widget => { const parser = new WidgetParser(widget, availableMetrics, orgSlug); return parser.parse(); }) ); return { title: dashboard.title, description: dashboard.description, widgets: results.map(r => r.widget).filter(Boolean) as Widget[], report: results.flatMap(r => r.report), }; } const SUPPORTED_COLUMNS = new Set(['avg', 'max', 'min', 'sum', 'value']); const SUPPORTED_WIDGET_TYPES = new Set(['timeseries']); const METRIC_SUFFIX_TO_AGGREGATION = { avg: 'avg', max: 'max', min: 'min', sum: 'sum', count: 'count', '50percentile': 'p50', '75percentile': 'p75', '90percentile': 'p90', '95percentile': 'p95', '99percentile': 'p99', }; export class WidgetParser { private errors: string[] = []; private api = new Client(); private importedWidget: ImportWidget; private availableMetrics: MetricMeta[]; private orgSlug: string; constructor( importedWidget: ImportWidget, availableMetrics: MetricMeta[], orgSlug: string ) { this.importedWidget = importedWidget; this.availableMetrics = availableMetrics; this.orgSlug = orgSlug; } // Parsing functions public async parse() { const { id, definition: {title, type: widgetType}, } = this.importedWidget; try { if (!SUPPORTED_WIDGET_TYPES.has(widgetType)) { throw new Error(`widget - unsupported type ${widgetType}`); } const widget = await this.parseWidget(); if (!widget || !widget.queries.length) { throw new Error('widget - no parseable queries found'); } const outcome: ImportOutcome = this.errors.length ? 'warning' : 'success'; return { report: { id, title, errors: this.errors, outcome, }, widget, }; } catch (e) { return { report: { id, title, errors: [e.message, ...this.errors], outcome: 'error' as const, }, widget: null, }; } } private async parseWidget() { this.parseLegendColumns(); const {title, requests = []} = this.importedWidget.definition as WidgetDefinition; const parsedRequests = requests.map(r => this.parseRequest(r)); const parsedQueries = parsedRequests.flatMap(request => request.queries); const metricsQueries = await Promise.all( parsedQueries.map(async query => { const mapped = await this.mapToMetricsQuery(query); return { ...mapped, }; }) ); const nonEmptyQueries = metricsQueries.filter(query => query.mri) as MetricsQuery[]; if (!nonEmptyQueries.length) { return null; } const metricsEquations = parsedRequests .flatMap(request => request.equations) .map(equation => this.mapToMetricsEquation(equation.formula)); return convertToDashboardWidget( [...nonEmptyQueries, ...metricsEquations], parsedRequests[0].displayType, title ); } private parseLegendColumns() { (this.importedWidget.definition?.legend_columns ?? []).forEach(column => { if (!SUPPORTED_COLUMNS.has(column)) { this.errors.push(`widget - unsupported column: ${column}`); } }); } private parseRequest(request: Request) { const {queries, formulas = [], response_format, display_type} = request; const parsedQueries = queries .map(query => this.parseQuery(query)) .sort((a, b) => a!.name.localeCompare(b!.name)); if (response_format !== 'timeseries') { this.errors.push( `widget.request.response_format - unsupported: ${response_format}` ); } const equationFormulas = formulas.filter(f => // indicates a more complex formula and not just a reference to a query f.formula.trim().includes(' ') ); const parsedEquations = this.parseEquations(parsedQueries, equationFormulas); const displayType = this.parseDisplayType(display_type); this.parseStyle(request.style); return { displayType, queries: parsedQueries, equations: parsedEquations, }; } // swaps query names with query symbols in formulas eg. query1 + $query0 => $b + $a private parseEquations(queries: any[], formulas: Formula[]) { const queryNames = queries.map(q => q.name); const queryNameMap = queries.reduce((acc, query, index) => { acc[query.name] = getQuerySymbol(index); return acc; }, {}); const equations = formulas.map(formula => { const {formula: formulaString, alias} = formula; const mapped = queryNames.reduce((acc, queryName) => { return acc.replaceAll(queryName, `$${queryNameMap[queryName]}`); }, formulaString); return { formula: mapped, alias, }; }); return equations; } private parseDisplayType(displayType: string) { switch (displayType) { case 'area': return MetricDisplayType.AREA; case 'bars': return MetricDisplayType.BAR; case 'line': return MetricDisplayType.LINE; default: this.errors.push( `widget.request.display_type - unsupported: ${displayType}, assuming line` ); return MetricDisplayType.LINE; } } private parseStyle(style?: Request['style']) { if (style?.line_type === 'dotted') { this.errors.push( `widget.request.style - unsupported line type: ${style.line_type}` ); } } private parseQuery(query: {name: string; query: string}) { return {...this.parseQueryString(query.query), name: query.name}; } private parseQueryString(str: string) { const aggregationMatch = str.match(/^(sum|avg|max|min):/); let aggregation = aggregationMatch ? aggregationMatch[1] : undefined; const metricNameMatch = str.match(/:(\S*){/); let metric = metricNameMatch ? metricNameMatch[1] : undefined; if (metric?.includes('.')) { const lastIndex = metric.lastIndexOf('.'); const metricName = metric.slice(0, lastIndex); const aggregationSuffix = metric.slice(lastIndex + 1); if (METRIC_SUFFIX_TO_AGGREGATION[aggregationSuffix]) { aggregation = METRIC_SUFFIX_TO_AGGREGATION[aggregationSuffix]; metric = metricName; } } const filtersMatch = str.match(/{([^}]*)}/); const filters = filtersMatch ? this.parseFilters(filtersMatch[1]) : []; const groupByMatch = str.match(/by {([^}]*)}/); const groupBy = groupByMatch ? this.parseGroupByValues(groupByMatch[1]) : []; const appliedFunctionMatch = str.match(/\.(\w+)\(\)/); const appliedFunction = appliedFunctionMatch ? appliedFunctionMatch[1] : undefined; if (!aggregation) { this.errors.push( `widget.request.query - could not parse aggregation: ${str}, assuming sum` ); aggregation = 'sum'; } if (!metric) { this.errors.push( `widget.request.query - could not parse name: ${str}, assuming ${metric}` ); metric = 'sentry.event_manager.save'; } // TODO: check which other functions are supported if (appliedFunction) { if (appliedFunction === 'as_count') { aggregation = 'sum'; this.errors.push( `widget.request.query - unsupported function ${appliedFunction}, assuming sum` ); } else { this.errors.push( `widget.request.query - unsupported function ${appliedFunction}` ); } } return { aggregation, metric, filters, groupBy, appliedFunction, }; } // Helper functions private parseFilters(filtersString) { const filters: any[] = []; const pairs = filtersString.split(','); for (const pair of pairs) { const [key, value] = pair.split(':'); if (!key || !value) { continue; } if (value.includes('*')) { const stripped = value.replace(/\*/g, ''); this.errors.push( `widget.request.query.filter - unsupported value: ${value}, using ${stripped}` ); if (stripped) { filters.push({key: key.trim(), value: stripped.trim()}); } continue; } filters.push({key: key.trim(), value: value.trim()}); } return filters; } private parseGroupByValues(groupByString) { return groupByString.split(',').map(value => value.trim()); } // Mapping functions private async mapToMetricsQuery(widget): Promise { const {metric, aggregation, filters} = widget; // @ts-expect-error name is actually defined on MetricMeta const metricMeta = this.availableMetrics.find(m => m.name === metric); if (!metricMeta) { this.errors.push(`widget.request.query - metric not found: ${metric}`); return null; } const availableTags = await this.fetchAvailableTags(metricMeta.mri); const query = this.constructMetricQueryFilter(filters, availableTags); const groupBy = this.constructMetricGroupBy(widget.groupBy, availableTags); return { mri: metricMeta.mri, aggregation, query, groupBy, }; } private mapToMetricsEquation(formula: string) { return { type: 'formula', formula, }; } private async fetchAvailableTags(mri: MRI) { const tagsRes = await this.api.requestPromise( `/organizations/${this.orgSlug}/metrics/tags/`, { query: { metric: mri, useCase: 'custom', }, } ); return (tagsRes ?? []).map(tag => tag.key); } private constructMetricQueryFilter( filters: {key: string; value: string}[], availableTags: string[] ) { const queryFilters = filters.map(filter => { const {key, value} = filter; if (!availableTags.includes(key)) { this.errors.push(`widget.request.query - unsupported filter: ${key}`); return null; } return `${key}:${value}`; }); return queryFilters.filter(Boolean).join(' '); } private constructMetricGroupBy(groupBy: string[], availableTags: string[]): string[] { return groupBy.filter(group => { if (!availableTags.includes(group)) { this.errors.push(`widget.request.query - unsupported group by: ${group}`); return false; } return true; }); } }