utils.tsx 14 KB

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