utils.tsx 16 KB

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