utils.tsx 25 KB

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