utils.tsx 16 KB

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