utils.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. import {urlEncode} from '@sentry/utils';
  2. import type {Location, Query} from 'history';
  3. import * as Papa from 'papaparse';
  4. import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
  5. import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  6. import {URL_PARAM} from 'sentry/constants/pageFilters';
  7. import {t} from 'sentry/locale';
  8. import type {SelectValue} from 'sentry/types/core';
  9. import type {Event} from 'sentry/types/event';
  10. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  11. import type {
  12. NewQuery,
  13. Organization,
  14. OrganizationSummary,
  15. } from 'sentry/types/organization';
  16. import type {Project} from 'sentry/types/project';
  17. import {defined} from 'sentry/utils';
  18. import {getUtcDateString} from 'sentry/utils/dates';
  19. import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  20. import type {EventData} from 'sentry/utils/discover/eventView';
  21. import type EventView from 'sentry/utils/discover/eventView';
  22. import type {
  23. Aggregation,
  24. Column,
  25. ColumnType,
  26. ColumnValueType,
  27. Field,
  28. } from 'sentry/utils/discover/fields';
  29. import {
  30. aggregateFunctionOutputType,
  31. AGGREGATIONS,
  32. explodeFieldString,
  33. getAggregateAlias,
  34. getAggregateArg,
  35. getColumnsAndAggregates,
  36. getEquation,
  37. isAggregateEquation,
  38. isAggregateFieldOrEquation,
  39. isEquation,
  40. isMeasurement,
  41. isSpanOperationBreakdownField,
  42. measurementType,
  43. PROFILING_FIELDS,
  44. TRACING_FIELDS,
  45. } from 'sentry/utils/discover/fields';
  46. import {DisplayModes, SavedQueryDatasets, TOP_N} from 'sentry/utils/discover/types';
  47. import {getTitle} from 'sentry/utils/events';
  48. import {DISCOVER_FIELDS, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  49. import localStorage from 'sentry/utils/localStorage';
  50. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  51. import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate';
  52. import {convertWidgetToBuilderStateParams} from 'sentry/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams';
  53. import {
  54. DashboardWidgetSource,
  55. DisplayType,
  56. type Widget,
  57. type WidgetQuery,
  58. WidgetType,
  59. } from '../dashboards/types';
  60. import {transactionSummaryRouteWithQuery} from '../performance/transactionSummary/utils';
  61. import {displayModeToDisplayType} from './savedQuery/utils';
  62. import type {FieldValue, TableColumn} from './table/types';
  63. import {FieldValueKind} from './table/types';
  64. import {getAllViews, getTransactionViews, getWebVitalsViews} from './data';
  65. export type QueryWithColumnState =
  66. | Query
  67. | {
  68. field: string | string[] | null | undefined;
  69. sort: string | string[] | null | undefined;
  70. };
  71. const TEMPLATE_TABLE_COLUMN: TableColumn<string> = {
  72. key: '',
  73. name: '',
  74. type: 'never',
  75. isSortable: false,
  76. column: Object.freeze({kind: 'field', field: ''}),
  77. width: COL_WIDTH_UNDEFINED,
  78. };
  79. export function decodeColumnOrder(fields: readonly Field[]): Array<TableColumn<string>> {
  80. return fields.map((f: Field) => {
  81. const column: TableColumn<string> = {...TEMPLATE_TABLE_COLUMN};
  82. const col = explodeFieldString(f.field, f.alias);
  83. const columnName = f.field;
  84. if (isEquation(f.field)) {
  85. column.key = f.field;
  86. column.name = getEquation(columnName);
  87. column.type = 'number';
  88. } else {
  89. column.key = columnName;
  90. column.name = columnName;
  91. }
  92. column.width = f.width || COL_WIDTH_UNDEFINED;
  93. if (col.kind === 'function') {
  94. // Aggregations can have a strict outputType or they can inherit from their field.
  95. // Otherwise use the FIELDS data to infer types.
  96. const outputType = aggregateFunctionOutputType(col.function[0], col.function[1]);
  97. if (outputType !== null) {
  98. column.type = outputType;
  99. }
  100. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  101. const aggregate = AGGREGATIONS[col.function[0]];
  102. column.isSortable = aggregate?.isSortable;
  103. } else if (col.kind === 'field') {
  104. if (getFieldDefinition(col.field) !== null) {
  105. column.type = getFieldDefinition(col.field)?.valueType as ColumnValueType;
  106. } else if (isMeasurement(col.field)) {
  107. column.type = measurementType(col.field);
  108. } else if (isSpanOperationBreakdownField(col.field)) {
  109. column.type = 'duration';
  110. }
  111. }
  112. column.column = col;
  113. return column;
  114. });
  115. }
  116. export function pushEventViewToLocation(props: {
  117. location: Location;
  118. navigate: ReactRouter3Navigate;
  119. nextEventView: EventView;
  120. extraQuery?: Query;
  121. }) {
  122. const {navigate, location, nextEventView} = props;
  123. const extraQuery = props.extraQuery || {};
  124. const queryStringObject = nextEventView.generateQueryStringObject();
  125. navigate({
  126. ...location,
  127. query: {
  128. ...extraQuery,
  129. ...queryStringObject,
  130. },
  131. });
  132. }
  133. export function generateTitle({
  134. eventView,
  135. event,
  136. isHomepage,
  137. }: {
  138. eventView: EventView;
  139. event?: Event;
  140. isHomepage?: boolean;
  141. }) {
  142. const titles = [t('Discover')];
  143. if (isHomepage) {
  144. return t('Discover');
  145. }
  146. const eventViewName = eventView.name;
  147. if (typeof eventViewName === 'string' && String(eventViewName).trim().length > 0) {
  148. titles.push(String(eventViewName).trim());
  149. }
  150. const eventTitle = event ? getTitle(event).title : undefined;
  151. if (eventTitle) {
  152. titles.push(eventTitle);
  153. }
  154. titles.reverse();
  155. return titles.join(' — ');
  156. }
  157. export function getPrebuiltQueries(organization: Organization) {
  158. const views = [...getAllViews(organization)];
  159. if (organization.features.includes('performance-view')) {
  160. // insert transactions queries at index 2
  161. views.splice(2, 0, ...getTransactionViews(organization));
  162. views.push(...getWebVitalsViews(organization));
  163. }
  164. return views;
  165. }
  166. function disableMacros(value: string | null | boolean | number) {
  167. const unsafeCharacterRegex = /^[\=\+\-\@]/;
  168. if (typeof value === 'string' && `${value}`.match(unsafeCharacterRegex)) {
  169. return `'${value}`;
  170. }
  171. return value;
  172. }
  173. export function downloadAsCsv(tableData: any, columnOrder: any, filename: any) {
  174. const {data} = tableData;
  175. const headings = columnOrder.map((column: any) => column.name);
  176. const keys = columnOrder.map((column: any) => column.key);
  177. const csvContent = Papa.unparse({
  178. fields: headings,
  179. data: data.map((row: any) =>
  180. keys.map((key: any) => {
  181. return disableMacros(row[key]);
  182. })
  183. ),
  184. });
  185. // Need to also manually replace # since encodeURI skips them
  186. const encodedDataUrl = `data:text/csv;charset=utf8,${encodeURIComponent(csvContent)}`;
  187. // Create a download link then click it, this is so we can get a filename
  188. const link = document.createElement('a');
  189. const now = new Date();
  190. link.setAttribute('href', encodedDataUrl);
  191. link.setAttribute('download', `${filename} ${getUtcDateString(now)}.csv`);
  192. link.click();
  193. link.remove();
  194. // Make testing easier
  195. return encodedDataUrl;
  196. }
  197. const ALIASED_AGGREGATES_COLUMN = {
  198. last_seen: 'timestamp',
  199. failure_count: 'transaction.status',
  200. };
  201. /**
  202. * Convert an aggregate into the resulting column from a drilldown action.
  203. * The result is null if the drilldown results in the aggregate being removed.
  204. */
  205. function drilldownAggregate(
  206. func: Extract<Column, {kind: 'function'}>
  207. ): Extract<Column, {kind: 'field'}> | null {
  208. const key = func.function[0];
  209. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  210. const aggregation = AGGREGATIONS[key];
  211. let column = func.function[1];
  212. if (ALIASED_AGGREGATES_COLUMN.hasOwnProperty(key)) {
  213. // Some aggregates are just shortcuts to other aggregates with
  214. // predefined arguments so we can directly map them to the result.
  215. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  216. column = ALIASED_AGGREGATES_COLUMN[key];
  217. } else if (aggregation?.parameters?.[0]) {
  218. const parameter = aggregation.parameters[0];
  219. if (parameter.kind !== 'column') {
  220. // The aggregation does not accept a column as a parameter,
  221. // so we clear the column.
  222. column = '';
  223. } else if (!column && parameter.required === false) {
  224. // The parameter was not given for a non-required parameter,
  225. // so we fall back to the default.
  226. column = parameter.defaultValue;
  227. }
  228. } else {
  229. // The aggregation does not exist or does not have any parameters,
  230. // so we clear the column.
  231. column = '';
  232. }
  233. return column ? {kind: 'field', field: column} : null;
  234. }
  235. /**
  236. * Convert an aggregated query into one that does not have aggregates.
  237. * Will also apply additions conditions defined in `additionalConditions`
  238. * and generate conditions based on the `dataRow` parameter and the current fields
  239. * in the `eventView`.
  240. */
  241. export function getExpandedResults(
  242. eventView: EventView,
  243. additionalConditions: Record<string, string>,
  244. dataRow?: TableDataRow | Event
  245. ): EventView {
  246. const fieldSet = new Set();
  247. // Expand any functions in the resulting column, and dedupe the result.
  248. // Mark any column as null to remove it.
  249. const expandedColumns: Array<Column | null> = eventView.fields.map((field: Field) => {
  250. const exploded = explodeFieldString(field.field, field.alias);
  251. const column = exploded.kind === 'function' ? drilldownAggregate(exploded) : exploded;
  252. if (
  253. // if expanding the function failed
  254. column === null ||
  255. // the new column is already present
  256. fieldSet.has(column.field) ||
  257. // Skip aggregate equations, their functions will already be added so we just want to remove it
  258. isAggregateEquation(field.field)
  259. ) {
  260. return null;
  261. }
  262. fieldSet.add(column.field);
  263. return column;
  264. });
  265. // id should be default column when expanded results in no columns; but only if
  266. // the Discover query's columns is non-empty.
  267. // This typically occurs in Discover drilldowns.
  268. if (fieldSet.size === 0 && expandedColumns.length) {
  269. expandedColumns[0] = {kind: 'field', field: 'id'};
  270. }
  271. // update the columns according the expansion above
  272. const nextView = expandedColumns.reduceRight(
  273. (newView, column, index) =>
  274. column === null
  275. ? newView.withDeletedColumn(index, undefined)
  276. : newView.withUpdatedColumn(index, column, undefined),
  277. eventView.clone()
  278. );
  279. nextView.query = generateExpandedConditions(nextView, additionalConditions, dataRow);
  280. return nextView;
  281. }
  282. /**
  283. * Create additional conditions based on the fields in an EventView
  284. * and a datarow/event
  285. */
  286. function generateAdditionalConditions(
  287. eventView: EventView,
  288. dataRow?: TableDataRow | Event
  289. ): Record<string, string | string[]> {
  290. const specialKeys = Object.values(URL_PARAM);
  291. const conditions: Record<string, string | string[]> = {};
  292. if (!dataRow) {
  293. return conditions;
  294. }
  295. eventView.fields.forEach((field: Field) => {
  296. const column = explodeFieldString(field.field, field.alias);
  297. // Skip aggregate fields
  298. if (column.kind === 'function') {
  299. return;
  300. }
  301. const dataKey = getAggregateAlias(field.field);
  302. // Append the current field as a condition if it exists in the dataRow
  303. // Or is a simple key in the event. More complex deeply nested fields are
  304. // more challenging to get at as their location in the structure does not
  305. // match their name.
  306. if (dataRow.hasOwnProperty(dataKey)) {
  307. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  308. let value = dataRow[dataKey];
  309. if (Array.isArray(value)) {
  310. if (value.length > 1) {
  311. conditions[column.field] = value;
  312. return;
  313. }
  314. // An array with only one value is equivalent to the value itself.
  315. value = value[0];
  316. }
  317. // if the value will be quoted, then do not trim it as the whitespaces
  318. // may be important to the query and should not be trimmed
  319. const shouldQuote =
  320. value === null || value === undefined
  321. ? false
  322. : /[\s\(\)\\"]/g.test(String(value).trim());
  323. const nextValue =
  324. value === null || value === undefined
  325. ? ''
  326. : shouldQuote
  327. ? String(value)
  328. : String(value).trim();
  329. if (isMeasurement(column.field) && !nextValue) {
  330. // Do not add measurement conditions if nextValue is falsey.
  331. // It's expected that nextValue is a numeric value.
  332. return;
  333. }
  334. switch (column.field) {
  335. case 'timestamp':
  336. // normalize the "timestamp" field to ensure the payload works
  337. conditions[column.field] = getUtcDateString(nextValue);
  338. break;
  339. default:
  340. conditions[column.field] = nextValue;
  341. }
  342. }
  343. // If we have an event, check tags as well.
  344. if (dataRow.tags && Array.isArray(dataRow.tags)) {
  345. const tagIndex = dataRow.tags.findIndex(item => item.key === dataKey);
  346. if (tagIndex > -1) {
  347. const key = specialKeys.includes(column.field)
  348. ? `tags[${column.field}]`
  349. : column.field;
  350. const tagValue = dataRow.tags[tagIndex]!.value;
  351. conditions[key] = tagValue;
  352. }
  353. }
  354. });
  355. return conditions;
  356. }
  357. /**
  358. * Discover queries can query either Errors, Transactions or a combination
  359. * of the two datasets. This is a util to determine if the query will excusively
  360. * hit the Transactions dataset.
  361. */
  362. export function usesTransactionsDataset(eventView: EventView, yAxisValue: string[]) {
  363. let usesTransactions: boolean = false;
  364. const parsedQuery = new MutableSearch(eventView.query);
  365. for (let index = 0; index < yAxisValue.length; index++) {
  366. const yAxis = yAxisValue[index]!;
  367. const aggregateArg = getAggregateArg(yAxis) ?? '';
  368. if (isMeasurement(aggregateArg) || aggregateArg === 'transaction.duration') {
  369. usesTransactions = true;
  370. break;
  371. }
  372. const eventTypeFilter = parsedQuery.getFilterValues('event.type');
  373. if (
  374. eventTypeFilter.length > 0 &&
  375. eventTypeFilter.every(filter => filter === 'transaction')
  376. ) {
  377. usesTransactions = true;
  378. break;
  379. }
  380. }
  381. return usesTransactions;
  382. }
  383. function generateExpandedConditions(
  384. eventView: EventView,
  385. additionalConditions: Record<string, string>,
  386. dataRow?: TableDataRow | Event
  387. ): string {
  388. const parsedQuery = new MutableSearch(eventView.query);
  389. // Remove any aggregates from the search conditions.
  390. // otherwise, it'll lead to an invalid query result.
  391. for (const key in parsedQuery.filters) {
  392. const column = explodeFieldString(key);
  393. if (column.kind === 'function') {
  394. parsedQuery.removeFilter(key);
  395. }
  396. }
  397. const conditions: Record<string, string | string[]> = Object.assign(
  398. {},
  399. additionalConditions,
  400. generateAdditionalConditions(eventView, dataRow)
  401. );
  402. // Add additional conditions provided and generated.
  403. for (const key in conditions) {
  404. const value = conditions[key]!;
  405. if (Array.isArray(value)) {
  406. parsedQuery.setFilterValues(key, value);
  407. continue;
  408. }
  409. if (key === 'project.id') {
  410. eventView.project = [...eventView.project, parseInt(value, 10)];
  411. continue;
  412. }
  413. if (key === 'environment') {
  414. if (!eventView.environment.includes(value)) {
  415. eventView.environment = [...eventView.environment, value];
  416. }
  417. continue;
  418. }
  419. const column = explodeFieldString(key);
  420. // Skip aggregates as they will be invalid.
  421. if (column.kind === 'function') {
  422. continue;
  423. }
  424. parsedQuery.setFilterValues(key, [value]);
  425. }
  426. return parsedQuery.formatString();
  427. }
  428. type FieldGeneratorOpts = {
  429. organization: OrganizationSummary;
  430. aggregations?: Record<string, Aggregation>;
  431. customMeasurements?: Array<{functions: string[]; key: string}> | null;
  432. fieldKeys?: string[];
  433. measurementKeys?: string[] | null;
  434. spanOperationBreakdownKeys?: string[];
  435. tagKeys?: string[] | null;
  436. };
  437. export function generateFieldOptions({
  438. organization,
  439. tagKeys,
  440. measurementKeys,
  441. spanOperationBreakdownKeys,
  442. customMeasurements,
  443. aggregations = AGGREGATIONS,
  444. fieldKeys = DISCOVER_FIELDS,
  445. }: FieldGeneratorOpts) {
  446. let functions = Object.keys(aggregations);
  447. // Strip tracing features if the org doesn't have access.
  448. if (!organization.features.includes('performance-view')) {
  449. fieldKeys = fieldKeys.filter(item => !TRACING_FIELDS.includes(item));
  450. functions = functions.filter(item => !TRACING_FIELDS.includes(item));
  451. }
  452. // Strip profiling features if the org doesn't have access.
  453. if (!organization.features.includes('profiling')) {
  454. fieldKeys = fieldKeys.filter(item => !PROFILING_FIELDS.includes(item));
  455. }
  456. const fieldOptions: Record<string, SelectValue<FieldValue>> = {};
  457. // Index items by prefixed keys as custom tags can overlap both fields and
  458. // function names. Having a mapping makes finding the value objects easier
  459. // later as well.
  460. functions.forEach(func => {
  461. const ellipsis = aggregations[func]!.parameters.length ? '\u2026' : '';
  462. const parameters = aggregations[func]!.parameters.map(param => {
  463. const overrides = aggregations[func]!.getFieldOverrides;
  464. if (typeof overrides === 'undefined') {
  465. return param;
  466. }
  467. return {
  468. ...param,
  469. ...overrides({parameter: param}),
  470. };
  471. });
  472. fieldOptions[`function:${func}`] = {
  473. label: `${func}(${ellipsis})`,
  474. value: {
  475. kind: FieldValueKind.FUNCTION,
  476. meta: {
  477. name: func,
  478. parameters,
  479. },
  480. },
  481. };
  482. });
  483. fieldKeys.forEach(field => {
  484. fieldOptions[`field:${field}`] = {
  485. label: field,
  486. value: {
  487. kind: FieldValueKind.FIELD,
  488. meta: {
  489. name: field,
  490. dataType: (getFieldDefinition(field)?.valueType ??
  491. FieldValueType.STRING) as ColumnType,
  492. },
  493. },
  494. };
  495. });
  496. if (measurementKeys !== undefined && measurementKeys !== null) {
  497. measurementKeys.sort();
  498. measurementKeys.forEach(measurement => {
  499. fieldOptions[`measurement:${measurement}`] = {
  500. label: measurement,
  501. value: {
  502. kind: FieldValueKind.MEASUREMENT,
  503. meta: {name: measurement, dataType: measurementType(measurement)},
  504. },
  505. };
  506. });
  507. }
  508. if (customMeasurements !== undefined && customMeasurements !== null) {
  509. customMeasurements.sort(({key: currentKey}, {key: nextKey}) =>
  510. currentKey > nextKey ? 1 : currentKey === nextKey ? 0 : -1
  511. );
  512. customMeasurements.forEach(({key, functions: supportedFunctions}) => {
  513. fieldOptions[`measurement:${key}`] = {
  514. label: key,
  515. value: {
  516. kind: FieldValueKind.CUSTOM_MEASUREMENT,
  517. meta: {
  518. name: key,
  519. dataType: measurementType(key),
  520. functions: supportedFunctions,
  521. },
  522. },
  523. };
  524. });
  525. }
  526. if (Array.isArray(spanOperationBreakdownKeys)) {
  527. spanOperationBreakdownKeys.sort();
  528. spanOperationBreakdownKeys.forEach(breakdownField => {
  529. if (!fieldKeys.includes(breakdownField)) {
  530. // These span op breakdowns are sometimes included in the fieldKeys
  531. // so check before we add them, or else we surface duplicates
  532. fieldOptions[`span_op_breakdown:${breakdownField}`] = {
  533. label: breakdownField,
  534. value: {
  535. kind: FieldValueKind.BREAKDOWN,
  536. meta: {name: breakdownField, dataType: 'duration'},
  537. },
  538. };
  539. }
  540. });
  541. }
  542. if (tagKeys !== undefined && tagKeys !== null) {
  543. tagKeys.sort();
  544. tagKeys.forEach(tag => {
  545. const tagValue =
  546. fieldKeys.includes(tag) || aggregations.hasOwnProperty(tag)
  547. ? `tags[${tag}]`
  548. : tag;
  549. fieldOptions[`tag:${tag}`] = {
  550. label: tag,
  551. value: {
  552. kind: FieldValueKind.TAG,
  553. meta: {name: tagValue, dataType: 'string'},
  554. },
  555. };
  556. });
  557. }
  558. return fieldOptions;
  559. }
  560. const RENDER_PREBUILT_KEY = 'discover-render-prebuilt';
  561. export function shouldRenderPrebuilt(): boolean {
  562. const shouldRender = localStorage.getItem(RENDER_PREBUILT_KEY);
  563. return shouldRender === 'true' || shouldRender === null;
  564. }
  565. export function setRenderPrebuilt(value: boolean) {
  566. localStorage.setItem(RENDER_PREBUILT_KEY, value ? 'true' : 'false');
  567. }
  568. export function eventViewToWidgetQuery({
  569. eventView,
  570. yAxis,
  571. displayType,
  572. }: {
  573. displayType: DisplayType;
  574. eventView: EventView;
  575. yAxis?: string | string[];
  576. }) {
  577. const fields = eventView.fields.map(({field}) => field);
  578. const {columns, aggregates} = getColumnsAndAggregates(fields);
  579. const sort = eventView.sorts[0];
  580. const queryYAxis = typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'];
  581. let orderby = '';
  582. // The orderby should only be set to sort.field if it is a Top N query
  583. // since the query uses all of the fields, or if the ordering is used in the y-axis
  584. if (sort) {
  585. let orderbyFunction = '';
  586. const aggregateFields = [...queryYAxis, ...aggregates];
  587. for (let i = 0; i < aggregateFields.length; i++) {
  588. if (sort.field === getAggregateAlias(aggregateFields[i]!)) {
  589. orderbyFunction = aggregateFields[i]!;
  590. break;
  591. }
  592. }
  593. const bareOrderby = orderbyFunction === '' ? sort.field : orderbyFunction;
  594. if (displayType === DisplayType.TOP_N || bareOrderby) {
  595. orderby = `${sort.kind === 'desc' ? '-' : ''}${bareOrderby}`;
  596. }
  597. }
  598. let newAggregates = aggregates;
  599. if (displayType !== DisplayType.TABLE) {
  600. newAggregates = queryYAxis;
  601. }
  602. const widgetQuery: WidgetQuery = {
  603. name: '',
  604. aggregates: newAggregates,
  605. columns: [...(displayType === DisplayType.TOP_N ? columns : [])],
  606. fields: [...(displayType === DisplayType.TOP_N ? fields : []), ...queryYAxis],
  607. conditions: eventView.query,
  608. orderby,
  609. };
  610. return widgetQuery;
  611. }
  612. export function handleAddQueryToDashboard({
  613. eventView,
  614. location,
  615. query,
  616. organization,
  617. router,
  618. yAxis,
  619. widgetType,
  620. }: {
  621. eventView: EventView;
  622. location: Location;
  623. organization: Organization;
  624. router: InjectedRouter;
  625. widgetType: WidgetType | undefined;
  626. query?: NewQuery;
  627. yAxis?: string | string[];
  628. }) {
  629. const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
  630. const defaultWidgetQuery = eventViewToWidgetQuery({
  631. eventView,
  632. displayType,
  633. yAxis,
  634. });
  635. const {query: widgetAsQueryParams} = constructAddQueryToDashboardLink({
  636. eventView,
  637. query,
  638. organization,
  639. yAxis,
  640. location,
  641. widgetType,
  642. });
  643. openAddToDashboardModal({
  644. organization,
  645. selection: {
  646. projects: eventView.project.slice(),
  647. environments: eventView.environment.slice(),
  648. datetime: {
  649. start: eventView.start!,
  650. end: eventView.end!,
  651. period: eventView.statsPeriod!,
  652. // Previously undetected because the type used to rely on an implicit any value.
  653. // @ts-expect-error TS(2322): Type 'string | boolean | undefined' is not assigna... Remove this comment to see the full error message
  654. utc: eventView.utc,
  655. },
  656. },
  657. widget: {
  658. title: (query?.name ?? eventView.name)!,
  659. displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
  660. queries: [
  661. {
  662. ...defaultWidgetQuery,
  663. aggregates: [...(typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'])],
  664. ...(organization.features.includes('dashboards-widget-builder-redesign')
  665. ? {
  666. // The widget query params filters out aggregate fields
  667. // so we can use the fields as columns. This is so yAxes
  668. // can be grouped by the fields.
  669. fields: widgetAsQueryParams?.field ?? [],
  670. columns: widgetAsQueryParams?.field ?? [],
  671. }
  672. : {}),
  673. },
  674. ],
  675. interval: eventView.interval!,
  676. limit: widgetAsQueryParams?.limit,
  677. widgetType,
  678. },
  679. router,
  680. // Previously undetected because the type relied on implicit any.
  681. // @ts-expect-error TS(2322): Type '{ dataset?: WidgetType | undefined; descript... Remove this comment to see the full error message
  682. widgetAsQueryParams,
  683. location,
  684. });
  685. return;
  686. }
  687. export function getTargetForTransactionSummaryLink(
  688. dataRow: EventData,
  689. organization: Organization,
  690. projects?: Project[],
  691. nextView?: EventView,
  692. location?: Location
  693. ) {
  694. let projectID: string | string[] | undefined;
  695. const filterProjects = location?.query.project;
  696. if (typeof filterProjects === 'string' && filterProjects !== '-1') {
  697. // Project selector in discover has just one selected project
  698. projectID = filterProjects;
  699. } else {
  700. const projectMatch = projects?.find(
  701. project =>
  702. project.slug && [dataRow['project.name'], dataRow.project].includes(project.slug)
  703. );
  704. projectID = projectMatch ? [projectMatch.id] : undefined;
  705. }
  706. const target = transactionSummaryRouteWithQuery({
  707. orgSlug: organization.slug,
  708. transaction: String(dataRow.transaction),
  709. projectID,
  710. query: nextView?.getPageFiltersQuery() || {},
  711. });
  712. // Pass on discover filter params when there are multiple
  713. // projects associated with the transaction
  714. if (!projectID && filterProjects) {
  715. target.query.project = filterProjects;
  716. }
  717. return target;
  718. }
  719. export function constructAddQueryToDashboardLink({
  720. eventView,
  721. query,
  722. organization,
  723. yAxis,
  724. location,
  725. widgetType,
  726. }: {
  727. eventView: EventView;
  728. organization: Organization;
  729. location?: Location;
  730. query?: NewQuery;
  731. widgetType?: WidgetType;
  732. yAxis?: string | string[];
  733. }) {
  734. const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
  735. const defaultTableFields = eventView.fields.map(({field}) => field);
  736. const defaultWidgetQuery = eventViewToWidgetQuery({
  737. eventView,
  738. displayType,
  739. yAxis,
  740. });
  741. const defaultTitle =
  742. query?.name ?? (eventView.name !== 'All Events' ? eventView.name : undefined);
  743. const limit =
  744. displayType === DisplayType.TOP_N || eventView.display === DisplayModes.DAILYTOP5
  745. ? Number(eventView.topEvents) || TOP_N
  746. : undefined;
  747. if (organization.features.includes('dashboards-widget-builder-redesign')) {
  748. const widget: Widget = {
  749. title: defaultTitle!,
  750. displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
  751. widgetType,
  752. limit,
  753. interval: eventView.interval ?? '',
  754. queries: [
  755. {
  756. ...defaultWidgetQuery,
  757. aggregates: [...(typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'])],
  758. fields: eventView.getFields(),
  759. columns:
  760. widgetType === WidgetType.SPANS ||
  761. displayType === DisplayType.TOP_N ||
  762. eventView.display === DisplayModes.DAILYTOP5
  763. ? eventView
  764. .getFields()
  765. .filter(
  766. column => defined(column) && !isAggregateFieldOrEquation(column)
  767. )
  768. : [],
  769. },
  770. ],
  771. };
  772. return {
  773. pathname: `/organizations/${organization.slug}/dashboards/new/widget-builder/widget/new/`,
  774. query: {
  775. ...location?.query,
  776. start: eventView.start,
  777. end: eventView.end,
  778. statsPeriod: eventView.statsPeriod,
  779. ...convertWidgetToBuilderStateParams(widget),
  780. },
  781. };
  782. }
  783. return {
  784. pathname: `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  785. query: {
  786. ...location?.query,
  787. source: DashboardWidgetSource.DISCOVERV2,
  788. start: eventView.start,
  789. end: eventView.end,
  790. statsPeriod: eventView.statsPeriod,
  791. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  792. defaultTableColumns: defaultTableFields,
  793. defaultTitle,
  794. displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
  795. dataset: widgetType,
  796. field: eventView.getFields(),
  797. limit,
  798. },
  799. };
  800. }
  801. export const SAVED_QUERY_DATASET_TO_WIDGET_TYPE = {
  802. [SavedQueryDatasets.ERRORS]: WidgetType.ERRORS,
  803. [SavedQueryDatasets.TRANSACTIONS]: WidgetType.TRANSACTIONS,
  804. };