utils.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import {browserHistory} from 'react-router';
  2. import {Location, Query} from 'history';
  3. import * as Papa from 'papaparse';
  4. import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  5. import {URL_PARAM} from 'sentry/constants/pageFilters';
  6. import {t} from 'sentry/locale';
  7. import {Organization, SelectValue} from 'sentry/types';
  8. import {Event} from 'sentry/types/event';
  9. import {getUtcDateString} from 'sentry/utils/dates';
  10. import {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  11. import EventView from 'sentry/utils/discover/eventView';
  12. import {
  13. aggregateFunctionOutputType,
  14. Aggregation,
  15. AGGREGATIONS,
  16. Column,
  17. ColumnType,
  18. explodeFieldString,
  19. Field,
  20. FIELDS,
  21. getAggregateAlias,
  22. getEquation,
  23. isAggregateEquation,
  24. isEquation,
  25. isMeasurement,
  26. isSpanOperationBreakdownField,
  27. measurementType,
  28. TRACING_FIELDS,
  29. } from 'sentry/utils/discover/fields';
  30. import {getTitle} from 'sentry/utils/events';
  31. import localStorage from 'sentry/utils/localStorage';
  32. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  33. import {FieldValue, FieldValueKind, TableColumn} from './table/types';
  34. import {ALL_VIEWS, TRANSACTION_VIEWS, WEB_VITALS_VIEWS} from './data';
  35. export type QueryWithColumnState =
  36. | Query
  37. | {
  38. field: string | string[] | null | undefined;
  39. sort: string | string[] | null | undefined;
  40. };
  41. const TEMPLATE_TABLE_COLUMN: TableColumn<string> = {
  42. key: '',
  43. name: '',
  44. type: 'never',
  45. isSortable: false,
  46. column: Object.freeze({kind: 'field', field: ''}),
  47. width: COL_WIDTH_UNDEFINED,
  48. };
  49. // TODO(mark) these types are coupled to the gridEditable component types and
  50. // I'd prefer the types to be more general purpose but that will require a second pass.
  51. export function decodeColumnOrder(fields: Readonly<Field[]>): TableColumn<string>[] {
  52. let equations = 0;
  53. return fields.map((f: Field) => {
  54. const column: TableColumn<string> = {...TEMPLATE_TABLE_COLUMN};
  55. const col = explodeFieldString(f.field, f.alias);
  56. const columnName = f.field;
  57. if (isEquation(f.field)) {
  58. column.key = `equation[${equations}]`;
  59. column.name = getEquation(columnName);
  60. equations += 1;
  61. } else {
  62. column.key = columnName;
  63. column.name = columnName;
  64. }
  65. column.width = f.width || COL_WIDTH_UNDEFINED;
  66. if (col.kind === 'function') {
  67. // Aggregations can have a strict outputType or they can inherit from their field.
  68. // Otherwise use the FIELDS data to infer types.
  69. const outputType = aggregateFunctionOutputType(col.function[0], col.function[1]);
  70. if (outputType !== null) {
  71. column.type = outputType;
  72. }
  73. const aggregate = AGGREGATIONS[col.function[0]];
  74. column.isSortable = aggregate && aggregate.isSortable;
  75. } else if (col.kind === 'field') {
  76. if (FIELDS.hasOwnProperty(col.field)) {
  77. column.type = FIELDS[col.field];
  78. } else if (isMeasurement(col.field)) {
  79. column.type = measurementType(col.field);
  80. } else if (isSpanOperationBreakdownField(col.field)) {
  81. column.type = 'duration';
  82. }
  83. }
  84. column.column = col;
  85. return column;
  86. });
  87. }
  88. export function pushEventViewToLocation(props: {
  89. location: Location;
  90. nextEventView: EventView;
  91. extraQuery?: Query;
  92. }) {
  93. const {location, nextEventView} = props;
  94. const extraQuery = props.extraQuery || {};
  95. const queryStringObject = nextEventView.generateQueryStringObject();
  96. browserHistory.push({
  97. ...location,
  98. query: {
  99. ...extraQuery,
  100. ...queryStringObject,
  101. },
  102. });
  103. }
  104. export function generateTitle({
  105. eventView,
  106. event,
  107. organization,
  108. }: {
  109. eventView: EventView;
  110. event?: Event;
  111. organization?: Organization;
  112. }) {
  113. const titles = [t('Discover')];
  114. const eventViewName = eventView.name;
  115. if (typeof eventViewName === 'string' && String(eventViewName).trim().length > 0) {
  116. titles.push(String(eventViewName).trim());
  117. }
  118. const eventTitle = event ? getTitle(event, organization?.features).title : undefined;
  119. if (eventTitle) {
  120. titles.push(eventTitle);
  121. }
  122. titles.reverse();
  123. return titles.join(' - ');
  124. }
  125. export function getPrebuiltQueries(organization: Organization) {
  126. const views = [...ALL_VIEWS];
  127. if (organization.features.includes('performance-view')) {
  128. // insert transactions queries at index 2
  129. views.splice(2, 0, ...TRANSACTION_VIEWS);
  130. views.push(...WEB_VITALS_VIEWS);
  131. }
  132. return views;
  133. }
  134. function disableMacros(value: string | null | boolean | number) {
  135. const unsafeCharacterRegex = /^[\=\+\-\@]/;
  136. if (typeof value === 'string' && `${value}`.match(unsafeCharacterRegex)) {
  137. return `'${value}`;
  138. }
  139. return value;
  140. }
  141. export function downloadAsCsv(tableData, columnOrder, filename) {
  142. const {data} = tableData;
  143. const headings = columnOrder.map(column => column.name);
  144. const csvContent = Papa.unparse({
  145. fields: headings,
  146. data: data.map(row =>
  147. headings.map(col => {
  148. col = getAggregateAlias(col);
  149. return disableMacros(row[col]);
  150. })
  151. ),
  152. });
  153. // Need to also manually replace # since encodeURI skips them
  154. const encodedDataUrl = `data:text/csv;charset=utf8,${encodeURIComponent(csvContent)}`;
  155. // Create a download link then click it, this is so we can get a filename
  156. const link = document.createElement('a');
  157. const now = new Date();
  158. link.setAttribute('href', encodedDataUrl);
  159. link.setAttribute('download', `${filename} ${getUtcDateString(now)}.csv`);
  160. link.click();
  161. link.remove();
  162. // Make testing easier
  163. return encodedDataUrl;
  164. }
  165. const ALIASED_AGGREGATES_COLUMN = {
  166. last_seen: 'timestamp',
  167. failure_count: 'transaction.status',
  168. };
  169. /**
  170. * Convert an aggregate into the resulting column from a drilldown action.
  171. * The result is null if the drilldown results in the aggregate being removed.
  172. */
  173. function drilldownAggregate(
  174. func: Extract<Column, {kind: 'function'}>
  175. ): Extract<Column, {kind: 'field'}> | null {
  176. const key = func.function[0];
  177. const aggregation = AGGREGATIONS[key];
  178. let column = func.function[1];
  179. if (ALIASED_AGGREGATES_COLUMN.hasOwnProperty(key)) {
  180. // Some aggregates are just shortcuts to other aggregates with
  181. // predefined arguments so we can directly map them to the result.
  182. column = ALIASED_AGGREGATES_COLUMN[key];
  183. } else if (aggregation?.parameters?.[0]) {
  184. const parameter = aggregation.parameters[0];
  185. if (parameter.kind !== 'column') {
  186. // The aggregation does not accept a column as a parameter,
  187. // so we clear the column.
  188. column = '';
  189. } else if (!column && parameter.required === false) {
  190. // The parameter was not given for a non-required parameter,
  191. // so we fall back to the default.
  192. column = parameter.defaultValue;
  193. }
  194. } else {
  195. // The aggregation does not exist or does not have any parameters,
  196. // so we clear the column.
  197. column = '';
  198. }
  199. return column ? {kind: 'field', field: column} : null;
  200. }
  201. /**
  202. * Convert an aggregated query into one that does not have aggregates.
  203. * Will also apply additions conditions defined in `additionalConditions`
  204. * and generate conditions based on the `dataRow` parameter and the current fields
  205. * in the `eventView`.
  206. */
  207. export function getExpandedResults(
  208. eventView: EventView,
  209. additionalConditions: Record<string, string>,
  210. dataRow?: TableDataRow | Event
  211. ): EventView {
  212. const fieldSet = new Set();
  213. // Expand any functions in the resulting column, and dedupe the result.
  214. // Mark any column as null to remove it.
  215. const expandedColumns: (Column | null)[] = eventView.fields.map((field: Field) => {
  216. const exploded = explodeFieldString(field.field, field.alias);
  217. const column = exploded.kind === 'function' ? drilldownAggregate(exploded) : exploded;
  218. if (
  219. // if expanding the function failed
  220. column === null ||
  221. // the new column is already present
  222. fieldSet.has(column.field) ||
  223. // Skip aggregate equations, their functions will already be added so we just want to remove it
  224. isAggregateEquation(field.field)
  225. ) {
  226. return null;
  227. }
  228. fieldSet.add(column.field);
  229. return column;
  230. });
  231. // id should be default column when expanded results in no columns; but only if
  232. // the Discover query's columns is non-empty.
  233. // This typically occurs in Discover drilldowns.
  234. if (fieldSet.size === 0 && expandedColumns.length) {
  235. expandedColumns[0] = {kind: 'field', field: 'id'};
  236. }
  237. // update the columns according the the expansion above
  238. const nextView = expandedColumns.reduceRight(
  239. (newView, column, index) =>
  240. column === null
  241. ? newView.withDeletedColumn(index, undefined)
  242. : newView.withUpdatedColumn(index, column, undefined),
  243. eventView.clone()
  244. );
  245. nextView.query = generateExpandedConditions(nextView, additionalConditions, dataRow);
  246. return nextView;
  247. }
  248. /**
  249. * Create additional conditions based on the fields in an EventView
  250. * and a datarow/event
  251. */
  252. function generateAdditionalConditions(
  253. eventView: EventView,
  254. dataRow?: TableDataRow | Event
  255. ): Record<string, string | string[]> {
  256. const specialKeys = Object.values(URL_PARAM);
  257. const conditions: Record<string, string | string[]> = {};
  258. if (!dataRow) {
  259. return conditions;
  260. }
  261. eventView.fields.forEach((field: Field) => {
  262. const column = explodeFieldString(field.field, field.alias);
  263. // Skip aggregate fields
  264. if (column.kind === 'function') {
  265. return;
  266. }
  267. const dataKey = getAggregateAlias(field.field);
  268. // Append the current field as a condition if it exists in the dataRow
  269. // Or is a simple key in the event. More complex deeply nested fields are
  270. // more challenging to get at as their location in the structure does not
  271. // match their name.
  272. if (dataRow.hasOwnProperty(dataKey)) {
  273. let value = dataRow[dataKey];
  274. if (Array.isArray(value)) {
  275. if (value.length > 1) {
  276. conditions[column.field] = value;
  277. return;
  278. }
  279. // An array with only one value is equivalent to the value itself.
  280. value = value[0];
  281. }
  282. // if the value will be quoted, then do not trim it as the whitespaces
  283. // may be important to the query and should not be trimmed
  284. const shouldQuote =
  285. value === null || value === undefined
  286. ? false
  287. : /[\s\(\)\\"]/g.test(String(value).trim());
  288. const nextValue =
  289. value === null || value === undefined
  290. ? ''
  291. : shouldQuote
  292. ? String(value)
  293. : String(value).trim();
  294. if (isMeasurement(column.field) && !nextValue) {
  295. // Do not add measurement conditions if nextValue is falsey.
  296. // It's expected that nextValue is a numeric value.
  297. return;
  298. }
  299. switch (column.field) {
  300. case 'timestamp':
  301. // normalize the "timestamp" field to ensure the payload works
  302. conditions[column.field] = getUtcDateString(nextValue);
  303. break;
  304. default:
  305. conditions[column.field] = nextValue;
  306. }
  307. }
  308. // If we have an event, check tags as well.
  309. if (dataRow.tags && Array.isArray(dataRow.tags)) {
  310. const tagIndex = dataRow.tags.findIndex(item => item.key === dataKey);
  311. if (tagIndex > -1) {
  312. const key = specialKeys.includes(column.field)
  313. ? `tags[${column.field}]`
  314. : column.field;
  315. const tagValue = dataRow.tags[tagIndex].value;
  316. conditions[key] = tagValue;
  317. }
  318. }
  319. });
  320. return conditions;
  321. }
  322. function generateExpandedConditions(
  323. eventView: EventView,
  324. additionalConditions: Record<string, string>,
  325. dataRow?: TableDataRow | Event
  326. ): string {
  327. const parsedQuery = new MutableSearch(eventView.query);
  328. // Remove any aggregates from the search conditions.
  329. // otherwise, it'll lead to an invalid query result.
  330. for (const key in parsedQuery.filters) {
  331. const column = explodeFieldString(key);
  332. if (column.kind === 'function') {
  333. parsedQuery.removeFilter(key);
  334. }
  335. }
  336. const conditions: Record<string, string | string[]> = Object.assign(
  337. {},
  338. additionalConditions,
  339. generateAdditionalConditions(eventView, dataRow)
  340. );
  341. // Add additional conditions provided and generated.
  342. for (const key in conditions) {
  343. const value = conditions[key];
  344. if (Array.isArray(value)) {
  345. parsedQuery.setFilterValues(key, value);
  346. continue;
  347. }
  348. if (key === 'project.id') {
  349. eventView.project = [...eventView.project, parseInt(value, 10)];
  350. continue;
  351. }
  352. if (key === 'environment') {
  353. if (!eventView.environment.includes(value)) {
  354. eventView.environment = [...eventView.environment, value];
  355. }
  356. continue;
  357. }
  358. const column = explodeFieldString(key);
  359. // Skip aggregates as they will be invalid.
  360. if (column.kind === 'function') {
  361. continue;
  362. }
  363. parsedQuery.setFilterValues(key, [value]);
  364. }
  365. return parsedQuery.formatString();
  366. }
  367. type FieldGeneratorOpts = {
  368. organization: Organization;
  369. aggregations?: Record<string, Aggregation>;
  370. fields?: Record<string, ColumnType>;
  371. measurementKeys?: string[] | null;
  372. spanOperationBreakdownKeys?: string[];
  373. tagKeys?: string[] | null;
  374. };
  375. export function generateFieldOptions({
  376. organization,
  377. tagKeys,
  378. measurementKeys,
  379. spanOperationBreakdownKeys,
  380. aggregations = AGGREGATIONS,
  381. fields = FIELDS,
  382. }: FieldGeneratorOpts) {
  383. let fieldKeys = Object.keys(fields).sort();
  384. let functions = Object.keys(aggregations);
  385. // Strip tracing features if the org doesn't have access.
  386. if (!organization.features.includes('performance-view')) {
  387. fieldKeys = fieldKeys.filter(item => !TRACING_FIELDS.includes(item));
  388. functions = functions.filter(item => !TRACING_FIELDS.includes(item));
  389. }
  390. const fieldOptions: Record<string, SelectValue<FieldValue>> = {};
  391. // Index items by prefixed keys as custom tags can overlap both fields and
  392. // function names. Having a mapping makes finding the value objects easier
  393. // later as well.
  394. functions.forEach(func => {
  395. const ellipsis = aggregations[func].parameters.length ? '\u2026' : '';
  396. const parameters = aggregations[func].parameters.map(param => {
  397. const overrides = AGGREGATIONS[func].getFieldOverrides;
  398. if (typeof overrides === 'undefined') {
  399. return param;
  400. }
  401. return {
  402. ...param,
  403. ...overrides({parameter: param}),
  404. };
  405. });
  406. fieldOptions[`function:${func}`] = {
  407. label: `${func}(${ellipsis})`,
  408. value: {
  409. kind: FieldValueKind.FUNCTION,
  410. meta: {
  411. name: func,
  412. parameters,
  413. },
  414. },
  415. };
  416. });
  417. fieldKeys.forEach(field => {
  418. fieldOptions[`field:${field}`] = {
  419. label: field,
  420. value: {
  421. kind: FieldValueKind.FIELD,
  422. meta: {
  423. name: field,
  424. dataType: fields[field],
  425. },
  426. },
  427. };
  428. });
  429. if (measurementKeys !== undefined && measurementKeys !== null) {
  430. measurementKeys.sort();
  431. measurementKeys.forEach(measurement => {
  432. fieldOptions[`measurement:${measurement}`] = {
  433. label: measurement,
  434. value: {
  435. kind: FieldValueKind.MEASUREMENT,
  436. meta: {name: measurement, dataType: measurementType(measurement)},
  437. },
  438. };
  439. });
  440. }
  441. if (Array.isArray(spanOperationBreakdownKeys)) {
  442. spanOperationBreakdownKeys.sort();
  443. spanOperationBreakdownKeys.forEach(breakdownField => {
  444. fieldOptions[`span_op_breakdown:${breakdownField}`] = {
  445. label: breakdownField,
  446. value: {
  447. kind: FieldValueKind.BREAKDOWN,
  448. meta: {name: breakdownField, dataType: 'duration'},
  449. },
  450. };
  451. });
  452. }
  453. if (tagKeys !== undefined && tagKeys !== null) {
  454. tagKeys.sort();
  455. tagKeys.forEach(tag => {
  456. const tagValue =
  457. fields.hasOwnProperty(tag) || AGGREGATIONS.hasOwnProperty(tag)
  458. ? `tags[${tag}]`
  459. : tag;
  460. fieldOptions[`tag:${tag}`] = {
  461. label: tag,
  462. value: {
  463. kind: FieldValueKind.TAG,
  464. meta: {name: tagValue, dataType: 'string'},
  465. },
  466. };
  467. });
  468. }
  469. return fieldOptions;
  470. }
  471. const RENDER_PREBUILT_KEY = 'discover-render-prebuilt';
  472. export function shouldRenderPrebuilt(): boolean {
  473. const shouldRender = localStorage.getItem(RENDER_PREBUILT_KEY);
  474. return shouldRender === 'true' || shouldRender === null;
  475. }
  476. export function setRenderPrebuilt(value: boolean) {
  477. localStorage.setItem(RENDER_PREBUILT_KEY, value ? 'true' : 'false');
  478. }