utils.tsx 12 KB

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