dashboardImport.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import {Client} from 'sentry/api';
  2. import {getQuerySymbol} from 'sentry/components/metrics/querySymbol';
  3. import type {MetricMeta, MRI} from 'sentry/types/metrics';
  4. import {convertToDashboardWidget} from 'sentry/utils/metrics/dashboard';
  5. import type {MetricsQuery} from 'sentry/utils/metrics/types';
  6. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  7. import type {Widget} from 'sentry/views/dashboards/types';
  8. // import types
  9. export type ImportDashboard = {
  10. description: string;
  11. title: string;
  12. widgets: ImportWidget[];
  13. };
  14. export type ImportWidget = {
  15. definition: WidgetDefinition;
  16. id: number;
  17. };
  18. type WidgetDefinition = {
  19. query: string;
  20. title: string;
  21. type: string;
  22. widgets: ImportWidget[];
  23. legend_columns?: ('avg' | 'max' | 'min' | 'sum' | 'value')[];
  24. requests?: Request[];
  25. };
  26. type Request = {
  27. display_type: 'area' | 'bars' | 'line';
  28. formulas: Formula[];
  29. queries: {
  30. data_source: string;
  31. name: string;
  32. query: string;
  33. }[];
  34. response_format: 'note' | 'timeseries';
  35. style?: {
  36. line_type: 'dotted' | 'solid';
  37. };
  38. };
  39. type Formula = {
  40. formula: string;
  41. alias?: string;
  42. };
  43. type MetricWidgetReport = {
  44. errors: string[];
  45. id: number;
  46. outcome: ImportOutcome;
  47. title: string;
  48. }[];
  49. type ImportOutcome = 'success' | 'warning' | 'error';
  50. export type ParseResult = {
  51. description: string;
  52. report: MetricWidgetReport;
  53. title: string;
  54. widgets: Widget[];
  55. };
  56. export async function parseDashboard(
  57. dashboard: ImportDashboard,
  58. availableMetrics: MetricMeta[],
  59. orgSlug: string
  60. ): Promise<ParseResult> {
  61. const {widgets = []} = dashboard;
  62. const flatWidgets = widgets.flatMap(widget => {
  63. if (widget.definition.type === 'group') {
  64. return widget.definition.widgets;
  65. }
  66. return [widget];
  67. });
  68. const results = await Promise.all(
  69. flatWidgets.map(widget => {
  70. const parser = new WidgetParser(widget, availableMetrics, orgSlug);
  71. return parser.parse();
  72. })
  73. );
  74. return {
  75. title: dashboard.title,
  76. description: dashboard.description,
  77. widgets: results.map(r => r.widget).filter(Boolean) as Widget[],
  78. report: results.flatMap(r => r.report),
  79. };
  80. }
  81. const SUPPORTED_COLUMNS = new Set(['avg', 'max', 'min', 'sum', 'value']);
  82. const SUPPORTED_WIDGET_TYPES = new Set(['timeseries']);
  83. const METRIC_SUFFIX_TO_AGGREGATION = {
  84. avg: 'avg',
  85. max: 'max',
  86. min: 'min',
  87. sum: 'sum',
  88. count: 'count',
  89. '50percentile': 'p50',
  90. '75percentile': 'p75',
  91. '90percentile': 'p90',
  92. '95percentile': 'p95',
  93. '99percentile': 'p99',
  94. };
  95. export class WidgetParser {
  96. private errors: string[] = [];
  97. private api = new Client();
  98. private importedWidget: ImportWidget;
  99. private availableMetrics: MetricMeta[];
  100. private orgSlug: string;
  101. constructor(
  102. importedWidget: ImportWidget,
  103. availableMetrics: MetricMeta[],
  104. orgSlug: string
  105. ) {
  106. this.importedWidget = importedWidget;
  107. this.availableMetrics = availableMetrics;
  108. this.orgSlug = orgSlug;
  109. }
  110. // Parsing functions
  111. public async parse() {
  112. const {
  113. id,
  114. definition: {title, type: widgetType},
  115. } = this.importedWidget;
  116. try {
  117. if (!SUPPORTED_WIDGET_TYPES.has(widgetType)) {
  118. throw new Error(`widget - unsupported type ${widgetType}`);
  119. }
  120. const widget = await this.parseWidget();
  121. if (!widget || !widget.queries.length) {
  122. throw new Error('widget - no parseable queries found');
  123. }
  124. const outcome: ImportOutcome = this.errors.length ? 'warning' : 'success';
  125. return {
  126. report: {
  127. id,
  128. title,
  129. errors: this.errors,
  130. outcome,
  131. },
  132. widget,
  133. };
  134. } catch (e) {
  135. return {
  136. report: {
  137. id,
  138. title,
  139. errors: [e.message, ...this.errors],
  140. outcome: 'error' as const,
  141. },
  142. widget: null,
  143. };
  144. }
  145. }
  146. private async parseWidget() {
  147. this.parseLegendColumns();
  148. const {title, requests = []} = this.importedWidget.definition as WidgetDefinition;
  149. const parsedRequests = requests.map(r => this.parseRequest(r));
  150. const parsedQueries = parsedRequests.flatMap(request => request.queries);
  151. const metricsQueries = await Promise.all(
  152. parsedQueries.map(async query => {
  153. const mapped = await this.mapToMetricsQuery(query);
  154. return {
  155. ...mapped,
  156. };
  157. })
  158. );
  159. const nonEmptyQueries = metricsQueries.filter(query => query.mri) as MetricsQuery[];
  160. if (!nonEmptyQueries.length) {
  161. return null;
  162. }
  163. const metricsEquations = parsedRequests
  164. .flatMap(request => request.equations)
  165. .map(equation => this.mapToMetricsEquation(equation.formula));
  166. return convertToDashboardWidget(
  167. [...nonEmptyQueries, ...metricsEquations],
  168. parsedRequests[0].displayType,
  169. title
  170. );
  171. }
  172. private parseLegendColumns() {
  173. (this.importedWidget.definition?.legend_columns ?? []).forEach(column => {
  174. if (!SUPPORTED_COLUMNS.has(column)) {
  175. this.errors.push(`widget - unsupported column: ${column}`);
  176. }
  177. });
  178. }
  179. private parseRequest(request: Request) {
  180. const {queries, formulas = [], response_format, display_type} = request;
  181. const parsedQueries = queries
  182. .map(query => this.parseQuery(query))
  183. .sort((a, b) => a!.name.localeCompare(b!.name));
  184. if (response_format !== 'timeseries') {
  185. this.errors.push(
  186. `widget.request.response_format - unsupported: ${response_format}`
  187. );
  188. }
  189. const equationFormulas = formulas.filter(f =>
  190. // indicates a more complex formula and not just a reference to a query
  191. f.formula.trim().includes(' ')
  192. );
  193. const parsedEquations = this.parseEquations(parsedQueries, equationFormulas);
  194. const displayType = this.parseDisplayType(display_type);
  195. this.parseStyle(request.style);
  196. return {
  197. displayType,
  198. queries: parsedQueries,
  199. equations: parsedEquations,
  200. };
  201. }
  202. // swaps query names with query symbols in formulas eg. query1 + $query0 => $b + $a
  203. private parseEquations(queries: any[], formulas: Formula[]) {
  204. const queryNames = queries.map(q => q.name);
  205. const queryNameMap = queries.reduce((acc, query, index) => {
  206. acc[query.name] = getQuerySymbol(index);
  207. return acc;
  208. }, {});
  209. const equations = formulas.map(formula => {
  210. const {formula: formulaString, alias} = formula;
  211. const mapped = queryNames.reduce((acc, queryName) => {
  212. return acc.replaceAll(queryName, `$${queryNameMap[queryName]}`);
  213. }, formulaString);
  214. return {
  215. formula: mapped,
  216. alias,
  217. };
  218. });
  219. return equations;
  220. }
  221. private parseDisplayType(displayType: string) {
  222. switch (displayType) {
  223. case 'area':
  224. return MetricDisplayType.AREA;
  225. case 'bars':
  226. return MetricDisplayType.BAR;
  227. case 'line':
  228. return MetricDisplayType.LINE;
  229. default:
  230. this.errors.push(
  231. `widget.request.display_type - unsupported: ${displayType}, assuming line`
  232. );
  233. return MetricDisplayType.LINE;
  234. }
  235. }
  236. private parseStyle(style?: Request['style']) {
  237. if (style?.line_type === 'dotted') {
  238. this.errors.push(
  239. `widget.request.style - unsupported line type: ${style.line_type}`
  240. );
  241. }
  242. }
  243. private parseQuery(query: {name: string; query: string}) {
  244. return {...this.parseQueryString(query.query), name: query.name};
  245. }
  246. private parseQueryString(str: string) {
  247. const aggregationMatch = str.match(/^(sum|avg|max|min):/);
  248. let aggregation = aggregationMatch ? aggregationMatch[1] : undefined;
  249. const metricNameMatch = str.match(/:(\S*){/);
  250. let metric = metricNameMatch ? metricNameMatch[1] : undefined;
  251. if (metric?.includes('.')) {
  252. const lastIndex = metric.lastIndexOf('.');
  253. const metricName = metric.slice(0, lastIndex);
  254. const aggregationSuffix = metric.slice(lastIndex + 1);
  255. if (METRIC_SUFFIX_TO_AGGREGATION[aggregationSuffix]) {
  256. aggregation = METRIC_SUFFIX_TO_AGGREGATION[aggregationSuffix];
  257. metric = metricName;
  258. }
  259. }
  260. const filtersMatch = str.match(/{([^}]*)}/);
  261. const filters = filtersMatch ? this.parseFilters(filtersMatch[1]) : [];
  262. const groupByMatch = str.match(/by {([^}]*)}/);
  263. const groupBy = groupByMatch ? this.parseGroupByValues(groupByMatch[1]) : [];
  264. const appliedFunctionMatch = str.match(/\.(\w+)\(\)/);
  265. const appliedFunction = appliedFunctionMatch ? appliedFunctionMatch[1] : undefined;
  266. if (!aggregation) {
  267. this.errors.push(
  268. `widget.request.query - could not parse aggregation: ${str}, assuming sum`
  269. );
  270. aggregation = 'sum';
  271. }
  272. if (!metric) {
  273. this.errors.push(
  274. `widget.request.query - could not parse name: ${str}, assuming ${metric}`
  275. );
  276. metric = 'sentry.event_manager.save';
  277. }
  278. // TODO: check which other functions are supported
  279. if (appliedFunction) {
  280. if (appliedFunction === 'as_count') {
  281. aggregation = 'sum';
  282. this.errors.push(
  283. `widget.request.query - unsupported function ${appliedFunction}, assuming sum`
  284. );
  285. } else {
  286. this.errors.push(
  287. `widget.request.query - unsupported function ${appliedFunction}`
  288. );
  289. }
  290. }
  291. return {
  292. aggregation,
  293. metric,
  294. filters,
  295. groupBy,
  296. appliedFunction,
  297. };
  298. }
  299. // Helper functions
  300. private parseFilters(filtersString) {
  301. const filters: any[] = [];
  302. const pairs = filtersString.split(',');
  303. for (const pair of pairs) {
  304. const [key, value] = pair.split(':');
  305. if (!key || !value) {
  306. continue;
  307. }
  308. if (value.includes('*')) {
  309. const stripped = value.replace(/\*/g, '');
  310. this.errors.push(
  311. `widget.request.query.filter - unsupported value: ${value}, using ${stripped}`
  312. );
  313. if (stripped) {
  314. filters.push({key: key.trim(), value: stripped.trim()});
  315. }
  316. continue;
  317. }
  318. filters.push({key: key.trim(), value: value.trim()});
  319. }
  320. return filters;
  321. }
  322. private parseGroupByValues(groupByString) {
  323. return groupByString.split(',').map(value => value.trim());
  324. }
  325. // Mapping functions
  326. private async mapToMetricsQuery(widget): Promise<MetricsQuery | null> {
  327. const {metric, aggregation, filters} = widget;
  328. // @ts-expect-error name is actually defined on MetricMeta
  329. const metricMeta = this.availableMetrics.find(m => m.name === metric);
  330. if (!metricMeta) {
  331. this.errors.push(`widget.request.query - metric not found: ${metric}`);
  332. return null;
  333. }
  334. const availableTags = await this.fetchAvailableTags(metricMeta.mri);
  335. const query = this.constructMetricQueryFilter(filters, availableTags);
  336. const groupBy = this.constructMetricGroupBy(widget.groupBy, availableTags);
  337. return {
  338. mri: metricMeta.mri,
  339. aggregation,
  340. query,
  341. groupBy,
  342. };
  343. }
  344. private mapToMetricsEquation(formula: string) {
  345. return {
  346. type: 'formula',
  347. formula,
  348. };
  349. }
  350. private async fetchAvailableTags(mri: MRI) {
  351. const tagsRes = await this.api.requestPromise(
  352. `/organizations/${this.orgSlug}/metrics/tags/`,
  353. {
  354. query: {
  355. metric: mri,
  356. useCase: 'custom',
  357. },
  358. }
  359. );
  360. return (tagsRes ?? []).map(tag => tag.key);
  361. }
  362. private constructMetricQueryFilter(
  363. filters: {key: string; value: string}[],
  364. availableTags: string[]
  365. ) {
  366. const queryFilters = filters.map(filter => {
  367. const {key, value} = filter;
  368. if (!availableTags.includes(key)) {
  369. this.errors.push(`widget.request.query - unsupported filter: ${key}`);
  370. return null;
  371. }
  372. return `${key}:${value}`;
  373. });
  374. return queryFilters.filter(Boolean).join(' ');
  375. }
  376. private constructMetricGroupBy(groupBy: string[], availableTags: string[]): string[] {
  377. return groupBy.filter(group => {
  378. if (!availableTags.includes(group)) {
  379. this.errors.push(`widget.request.query - unsupported group by: ${group}`);
  380. return false;
  381. }
  382. return true;
  383. });
  384. }
  385. }