dashboardImport.tsx 11 KB

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