utils.tsx 13 KB

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