utils.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import isEqual from 'lodash/isEqual';
  2. import trimStart from 'lodash/trimStart';
  3. import {generateOrderOptions} from 'sentry/components/dashboards/widgetQueriesForm';
  4. import {t} from 'sentry/locale';
  5. import {Organization, TagCollection} from 'sentry/types';
  6. import {
  7. aggregateFunctionOutputType,
  8. aggregateOutputType,
  9. getAggregateAlias,
  10. isEquation,
  11. isLegalYAxisType,
  12. stripDerivedMetricsPrefix,
  13. } from 'sentry/utils/discover/fields';
  14. import {MeasurementCollection} from 'sentry/utils/measurements/measurements';
  15. import {SPAN_OP_BREAKDOWN_FIELDS} from 'sentry/utils/performance/spanOperationBreakdowns/constants';
  16. import {
  17. DisplayType,
  18. Widget,
  19. WidgetQuery,
  20. WidgetType,
  21. } from 'sentry/views/dashboardsV2/types';
  22. import {FieldValueOption} from 'sentry/views/eventsV2/table/queryField';
  23. import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
  24. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  25. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  26. import {FlatValidationError, ValidationError} from '../utils';
  27. // Used in the widget builder to limit the number of lines plotted in the chart
  28. export const DEFAULT_RESULTS_LIMIT = 5;
  29. const RESULTS_LIMIT = 10;
  30. // Both dashboards and widgets use the 'new' keyword when creating
  31. export const NEW_DASHBOARD_ID = 'new';
  32. export enum DataSet {
  33. EVENTS = 'events',
  34. ISSUES = 'issues',
  35. RELEASE = 'release',
  36. }
  37. export enum SortDirection {
  38. HIGH_TO_LOW = 'high_to_low',
  39. LOW_TO_HIGH = 'low_to_high',
  40. }
  41. export const sortDirections = {
  42. [SortDirection.HIGH_TO_LOW]: t('High to low'),
  43. [SortDirection.LOW_TO_HIGH]: t('Low to high'),
  44. };
  45. export const displayTypes = {
  46. [DisplayType.AREA]: t('Area Chart'),
  47. [DisplayType.BAR]: t('Bar Chart'),
  48. [DisplayType.LINE]: t('Line Chart'),
  49. [DisplayType.TABLE]: t('Table'),
  50. [DisplayType.WORLD_MAP]: t('World Map'),
  51. [DisplayType.BIG_NUMBER]: t('Big Number'),
  52. [DisplayType.TOP_N]: t('Top 5 Events'),
  53. };
  54. export function mapErrors(
  55. data: ValidationError,
  56. update: FlatValidationError
  57. ): FlatValidationError {
  58. Object.keys(data).forEach((key: string) => {
  59. const value = data[key];
  60. if (typeof value === 'string') {
  61. update[key] = value;
  62. return;
  63. }
  64. // Recurse into nested objects.
  65. if (Array.isArray(value) && typeof value[0] === 'string') {
  66. update[key] = value[0];
  67. return;
  68. }
  69. if (Array.isArray(value) && typeof value[0] === 'object') {
  70. update[key] = (value as ValidationError[]).map(item => mapErrors(item, {}));
  71. } else {
  72. update[key] = mapErrors(value as ValidationError, {});
  73. }
  74. });
  75. return update;
  76. }
  77. export function normalizeQueries({
  78. displayType,
  79. queries,
  80. widgetType,
  81. widgetBuilderNewDesign = false,
  82. }: {
  83. displayType: DisplayType;
  84. queries: Widget['queries'];
  85. widgetBuilderNewDesign?: boolean;
  86. widgetType?: Widget['widgetType'];
  87. }): Widget['queries'] {
  88. const isTimeseriesChart = getIsTimeseriesChart(displayType);
  89. const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(displayType);
  90. if (
  91. [DisplayType.TABLE, DisplayType.WORLD_MAP, DisplayType.BIG_NUMBER].includes(
  92. displayType
  93. )
  94. ) {
  95. // Some display types may only support at most 1 query.
  96. queries = queries.slice(0, 1);
  97. } else if (isTimeseriesChart) {
  98. // Timeseries charts supports at most 3 queries.
  99. queries = queries.slice(0, 3);
  100. }
  101. if (widgetBuilderNewDesign) {
  102. queries = queries.map(query => {
  103. const {fields = [], columns} = query;
  104. if (isTabularChart) {
  105. // If the groupBy field has values, port everything over to the columnEditCollect field.
  106. query.fields = [...new Set([...fields, ...columns])];
  107. } else {
  108. // If columnEditCollect has field values , port everything over to the groupBy field.
  109. query.fields = fields.filter(field => !columns.includes(field));
  110. }
  111. if (
  112. getIsTimeseriesChart(displayType) &&
  113. !query.columns.filter(column => !!column).length
  114. ) {
  115. // The orderby is only applicable for timeseries charts when there's a
  116. // grouping selected, if all fields are empty then we also reset the orderby
  117. query.orderby = '';
  118. return query;
  119. }
  120. const queryOrderBy =
  121. widgetType === WidgetType.METRICS
  122. ? stripDerivedMetricsPrefix(queries[0].orderby)
  123. : queries[0].orderby;
  124. // Ignore the orderby if it is a raw equation and we're switching to a table
  125. // or Top-N chart, a custom equation should be reset since it only applies when
  126. // grouping in timeseries charts
  127. const ignoreOrderBy = isEquation(trimStart(queryOrderBy, '-')) && isTabularChart;
  128. const orderBy =
  129. (!ignoreOrderBy && getAggregateAlias(queryOrderBy)) ||
  130. (widgetType === WidgetType.ISSUE
  131. ? IssueSortOptions.DATE
  132. : generateOrderOptions({
  133. widgetType: widgetType ?? WidgetType.DISCOVER,
  134. widgetBuilderNewDesign,
  135. columns: queries[0].columns,
  136. aggregates: queries[0].aggregates,
  137. })[0].value);
  138. // Issues data set doesn't support order by descending
  139. query.orderby =
  140. widgetType === WidgetType.DISCOVER && !orderBy.startsWith('-')
  141. ? `-${String(orderBy)}`
  142. : String(orderBy);
  143. return query;
  144. });
  145. }
  146. if (isTabularChart) {
  147. return queries;
  148. }
  149. // Filter out non-aggregate fields
  150. queries = queries.map(query => {
  151. let aggregates = query.aggregates;
  152. if (isTimeseriesChart || displayType === DisplayType.WORLD_MAP) {
  153. // Filter out fields that will not generate numeric output types
  154. aggregates = aggregates.filter(aggregate =>
  155. isLegalYAxisType(aggregateOutputType(aggregate))
  156. );
  157. }
  158. if (isTimeseriesChart && aggregates.length && aggregates.length > 3) {
  159. // Timeseries charts supports at most 3 fields.
  160. aggregates = aggregates.slice(0, 3);
  161. }
  162. return {
  163. ...query,
  164. fields: aggregates.length ? aggregates : ['count()'],
  165. columns: widgetBuilderNewDesign && query.columns ? query.columns : [],
  166. aggregates: aggregates.length ? aggregates : ['count()'],
  167. };
  168. });
  169. if (isTimeseriesChart) {
  170. // For timeseries widget, all queries must share identical set of fields.
  171. const referenceAggregates = [...queries[0].aggregates];
  172. queryLoop: for (const query of queries) {
  173. if (referenceAggregates.length >= 3) {
  174. break;
  175. }
  176. if (isEqual(referenceAggregates, query.aggregates)) {
  177. continue;
  178. }
  179. for (const aggregate of query.aggregates) {
  180. if (referenceAggregates.length >= 3) {
  181. break queryLoop;
  182. }
  183. if (!referenceAggregates.includes(aggregate)) {
  184. referenceAggregates.push(aggregate);
  185. }
  186. }
  187. }
  188. queries = queries.map(query => {
  189. return {
  190. ...query,
  191. columns: widgetBuilderNewDesign && query.columns ? query.columns : [],
  192. aggregates: referenceAggregates,
  193. fields: referenceAggregates,
  194. };
  195. });
  196. }
  197. if ([DisplayType.WORLD_MAP, DisplayType.BIG_NUMBER].includes(displayType)) {
  198. // For world map chart, cap fields of the queries to only one field.
  199. queries = queries.map(query => {
  200. return {
  201. ...query,
  202. fields: query.aggregates.slice(0, 1),
  203. aggregates: query.aggregates.slice(0, 1),
  204. orderby: '',
  205. columns: [],
  206. };
  207. });
  208. }
  209. return queries;
  210. }
  211. export function getParsedDefaultWidgetQuery(query = ''): WidgetQuery | undefined {
  212. // "any" was needed here because it doesn't pass in getsentry
  213. const urlSeachParams = new URLSearchParams(query) as any;
  214. const parsedQuery = Object.fromEntries(urlSeachParams.entries());
  215. if (!Object.keys(parsedQuery).length) {
  216. return undefined;
  217. }
  218. const columns = parsedQuery.columns ? getFields(parsedQuery.columns) : [];
  219. const aggregates = parsedQuery.aggregates ? getFields(parsedQuery.aggregates) : [];
  220. const fields = [...columns, ...aggregates];
  221. return {
  222. ...parsedQuery,
  223. fields,
  224. columns,
  225. aggregates,
  226. } as WidgetQuery;
  227. }
  228. export function getFields(fieldsString: string): string[] {
  229. // Use a negative lookahead to avoid splitting on commas inside equation fields
  230. return fieldsString.split(/,(?![^(]*\))/g);
  231. }
  232. export function getAmendedFieldOptions({
  233. measurements,
  234. organization,
  235. tags,
  236. }: {
  237. measurements: MeasurementCollection;
  238. organization: Organization;
  239. tags: TagCollection;
  240. }) {
  241. return generateFieldOptions({
  242. organization,
  243. tagKeys: Object.values(tags).map(({key}) => key),
  244. measurementKeys: Object.values(measurements).map(({key}) => key),
  245. spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS,
  246. });
  247. }
  248. // Extract metric names from aggregation functions present in the widget queries
  249. export function getMetricFields(queries: WidgetQuery[]) {
  250. return queries.reduce((acc, query) => {
  251. for (const field of [...query.aggregates, ...query.columns]) {
  252. const fieldParameter = /\(([^)]*)\)/.exec(field)?.[1];
  253. if (fieldParameter && !acc.includes(fieldParameter)) {
  254. acc.push(fieldParameter);
  255. }
  256. }
  257. return acc;
  258. }, [] as string[]);
  259. }
  260. // Used to limit the number of results of the "filter your results" fields dropdown
  261. export const MAX_SEARCH_ITEMS = 5;
  262. // Used to set the max height of the smartSearchBar menu
  263. export const MAX_MENU_HEIGHT = 250;
  264. // Any function/field choice for Big Number widgets is legal since the
  265. // data source is from an endpoint that is not timeseries-based.
  266. // The function/field choice for World Map widget will need to be numeric-like.
  267. // Column builder for Table widget is already handled above.
  268. export function doNotValidateYAxis(displayType: DisplayType) {
  269. return displayType === DisplayType.BIG_NUMBER;
  270. }
  271. export function filterPrimaryOptions({
  272. option,
  273. widgetType,
  274. displayType,
  275. }: {
  276. displayType: DisplayType;
  277. option: FieldValueOption;
  278. widgetType?: WidgetType;
  279. }) {
  280. if (widgetType === WidgetType.METRICS) {
  281. if (displayType === DisplayType.TABLE) {
  282. return [
  283. FieldValueKind.FUNCTION,
  284. FieldValueKind.TAG,
  285. FieldValueKind.NUMERIC_METRICS,
  286. ].includes(option.value.kind);
  287. }
  288. if (displayType === DisplayType.TOP_N) {
  289. return option.value.kind === FieldValueKind.TAG;
  290. }
  291. }
  292. // Only validate function names for timeseries widgets and
  293. // world map widgets.
  294. if (!doNotValidateYAxis(displayType) && option.value.kind === FieldValueKind.FUNCTION) {
  295. const primaryOutput = aggregateFunctionOutputType(option.value.meta.name, undefined);
  296. if (primaryOutput) {
  297. // If a function returns a specific type, then validate it.
  298. return isLegalYAxisType(primaryOutput);
  299. }
  300. }
  301. return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
  302. option.value.kind
  303. );
  304. }
  305. export function getResultsLimit(numQueries: number, numYAxes: number) {
  306. if (numQueries === 0 || numYAxes === 0) {
  307. return DEFAULT_RESULTS_LIMIT;
  308. }
  309. return Math.floor(RESULTS_LIMIT / (numQueries * numYAxes));
  310. }
  311. export function getIsTimeseriesChart(displayType: DisplayType) {
  312. return [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(displayType);
  313. }