utils.tsx 12 KB

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