eventView.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363
  1. import {Location, Query} from 'history';
  2. import cloneDeep from 'lodash/cloneDeep';
  3. import isEqual from 'lodash/isEqual';
  4. import isString from 'lodash/isString';
  5. import omit from 'lodash/omit';
  6. import pick from 'lodash/pick';
  7. import uniqBy from 'lodash/uniqBy';
  8. import moment from 'moment';
  9. import {EventQuery} from 'app/actionCreators/events';
  10. import {COL_WIDTH_UNDEFINED} from 'app/components/gridEditable';
  11. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  12. import {DEFAULT_PER_PAGE} from 'app/constants';
  13. import {URL_PARAM} from 'app/constants/globalSelectionHeader';
  14. import {t} from 'app/locale';
  15. import {GlobalSelection, NewQuery, SavedQuery, SelectValue, User} from 'app/types';
  16. import {
  17. aggregateOutputType,
  18. Column,
  19. ColumnType,
  20. Field,
  21. generateFieldAsString,
  22. getAggregateAlias,
  23. getEquation,
  24. isAggregateEquation,
  25. isAggregateField,
  26. isEquation,
  27. isLegalYAxisType,
  28. Sort,
  29. WebVital,
  30. } from 'app/utils/discover/fields';
  31. import {decodeList, decodeScalar} from 'app/utils/queryString';
  32. import {
  33. FieldValueKind,
  34. TableColumn,
  35. TableColumnSort,
  36. } from 'app/views/eventsV2/table/types';
  37. import {decodeColumnOrder} from 'app/views/eventsV2/utils';
  38. import {SpanOperationBreakdownFilter} from 'app/views/performance/transactionSummary/filter';
  39. import {EventsDisplayFilterName} from 'app/views/performance/transactionSummary/transactionEvents/utils';
  40. import {statsPeriodToDays} from '../dates';
  41. import {QueryResults, tokenizeSearch} from '../tokenizeSearch';
  42. import {getSortField} from './fieldRenderers';
  43. import {
  44. CHART_AXIS_OPTIONS,
  45. DISPLAY_MODE_FALLBACK_OPTIONS,
  46. DISPLAY_MODE_OPTIONS,
  47. DisplayModes,
  48. } from './types';
  49. // Metadata mapping for discover results.
  50. export type MetaType = Record<string, ColumnType>;
  51. // Data in discover results.
  52. export type EventData = Record<string, any>;
  53. export type LocationQuery = {
  54. start?: string | string[];
  55. end?: string | string[];
  56. utc?: string | string[];
  57. statsPeriod?: string | string[];
  58. cursor?: string | string[];
  59. };
  60. const DATETIME_QUERY_STRING_KEYS = ['start', 'end', 'utc', 'statsPeriod'] as const;
  61. const EXTERNAL_QUERY_STRING_KEYS: Readonly<Array<keyof LocationQuery>> = [
  62. ...DATETIME_QUERY_STRING_KEYS,
  63. 'cursor',
  64. ];
  65. const setSortOrder = (sort: Sort, kind: 'desc' | 'asc'): Sort => ({
  66. kind,
  67. field: sort.field,
  68. });
  69. const reverseSort = (sort: Sort): Sort => ({
  70. kind: sort.kind === 'desc' ? 'asc' : 'desc',
  71. field: sort.field,
  72. });
  73. const isSortEqualToField = (
  74. sort: Sort,
  75. field: Field,
  76. tableMeta: MetaType | undefined
  77. ): boolean => {
  78. const sortKey = getSortKeyFromField(field, tableMeta);
  79. return sort.field === sortKey;
  80. };
  81. const fieldToSort = (
  82. field: Field,
  83. tableMeta: MetaType | undefined,
  84. kind?: 'desc' | 'asc'
  85. ): Sort | undefined => {
  86. const sortKey = getSortKeyFromField(field, tableMeta);
  87. if (!sortKey) {
  88. return void 0;
  89. }
  90. return {
  91. kind: kind || 'desc',
  92. field: sortKey,
  93. };
  94. };
  95. function getSortKeyFromField(field: Field, tableMeta?: MetaType): string | null {
  96. const alias = getAggregateAlias(field.field);
  97. return getSortField(alias, tableMeta);
  98. }
  99. export function isFieldSortable(field: Field, tableMeta?: MetaType): boolean {
  100. return !!getSortKeyFromField(field, tableMeta);
  101. }
  102. const decodeFields = (location: Location): Array<Field> => {
  103. const {query} = location;
  104. if (!query || !query.field) {
  105. return [];
  106. }
  107. const fields = decodeList(query.field);
  108. const widths = decodeList(query.widths);
  109. const parsed: Field[] = [];
  110. fields.forEach((field, i) => {
  111. const w = Number(widths[i]);
  112. const width = !isNaN(w) ? w : COL_WIDTH_UNDEFINED;
  113. parsed.push({field, width});
  114. });
  115. return parsed;
  116. };
  117. const parseSort = (sort: string): Sort => {
  118. sort = sort.trim();
  119. if (sort.startsWith('-')) {
  120. return {
  121. kind: 'desc',
  122. field: sort.substring(1),
  123. };
  124. }
  125. return {
  126. kind: 'asc',
  127. field: sort,
  128. };
  129. };
  130. export const fromSorts = (sorts: string | string[] | undefined): Array<Sort> => {
  131. if (sorts === undefined) {
  132. return [];
  133. }
  134. sorts = isString(sorts) ? [sorts] : sorts;
  135. // NOTE: sets are iterated in insertion order
  136. const uniqueSorts = [...new Set(sorts)];
  137. return uniqueSorts.reduce((acc: Array<Sort>, sort: string) => {
  138. acc.push(parseSort(sort));
  139. return acc;
  140. }, []);
  141. };
  142. const decodeSorts = (location: Location): Array<Sort> => {
  143. const {query} = location;
  144. if (!query || !query.sort) {
  145. return [];
  146. }
  147. const sorts = decodeList(query.sort);
  148. return fromSorts(sorts);
  149. };
  150. const encodeSort = (sort: Sort): string => {
  151. switch (sort.kind) {
  152. case 'desc': {
  153. return `-${sort.field}`;
  154. }
  155. case 'asc': {
  156. return String(sort.field);
  157. }
  158. default: {
  159. throw new Error('Unexpected sort type');
  160. }
  161. }
  162. };
  163. const encodeSorts = (sorts: Readonly<Array<Sort>>): Array<string> =>
  164. sorts.map(encodeSort);
  165. const collectQueryStringByKey = (query: Query, key: string): Array<string> => {
  166. const needle = query[key];
  167. const collection = decodeList(needle);
  168. return collection.reduce((acc: Array<string>, item: string) => {
  169. item = item.trim();
  170. if (item.length > 0) {
  171. acc.push(item);
  172. }
  173. return acc;
  174. }, []);
  175. };
  176. const decodeQuery = (location: Location): string => {
  177. if (!location.query || !location.query.query) {
  178. return '';
  179. }
  180. const queryParameter = location.query.query;
  181. return decodeScalar(queryParameter, '').trim();
  182. };
  183. const decodeTeam = (value: string): 'myteams' | number => {
  184. if (value === 'myteams') {
  185. return value;
  186. }
  187. return parseInt(value, 10);
  188. };
  189. const decodeTeams = (location: Location): ('myteams' | number)[] => {
  190. if (!location.query || !location.query.team) {
  191. return [];
  192. }
  193. const value = location.query.team;
  194. return Array.isArray(value) ? value.map(decodeTeam) : [decodeTeam(value)];
  195. };
  196. const decodeProjects = (location: Location): number[] => {
  197. if (!location.query || !location.query.project) {
  198. return [];
  199. }
  200. const value = location.query.project;
  201. return Array.isArray(value) ? value.map(i => parseInt(i, 10)) : [parseInt(value, 10)];
  202. };
  203. const queryStringFromSavedQuery = (saved: NewQuery | SavedQuery): string => {
  204. if (saved.query) {
  205. return saved.query || '';
  206. }
  207. return '';
  208. };
  209. function validateTableMeta(tableMeta: MetaType | undefined): MetaType | undefined {
  210. return tableMeta && Object.keys(tableMeta).length > 0 ? tableMeta : undefined;
  211. }
  212. class EventView {
  213. id: string | undefined;
  214. name: string | undefined;
  215. fields: Readonly<Field[]>;
  216. sorts: Readonly<Sort[]>;
  217. query: string;
  218. team: Readonly<('myteams' | number)[]>;
  219. project: Readonly<number[]>;
  220. start: string | undefined;
  221. end: string | undefined;
  222. statsPeriod: string | undefined;
  223. environment: Readonly<string[]>;
  224. yAxis: string | undefined;
  225. display: string | undefined;
  226. interval: string | undefined;
  227. expired?: boolean;
  228. createdBy: User | undefined;
  229. additionalConditions: QueryResults; // This allows views to always add additional conditins to the query to get specific data. It should not show up in the UI unless explicitly called.
  230. constructor(props: {
  231. id: string | undefined;
  232. name: string | undefined;
  233. fields: Readonly<Field[]>;
  234. sorts: Readonly<Sort[]>;
  235. query: string;
  236. team: Readonly<('myteams' | number)[]>;
  237. project: Readonly<number[]>;
  238. start: string | undefined;
  239. end: string | undefined;
  240. statsPeriod: string | undefined;
  241. environment: Readonly<string[]>;
  242. yAxis: string | undefined;
  243. display: string | undefined;
  244. interval?: string;
  245. expired?: boolean;
  246. createdBy: User | undefined;
  247. additionalConditions: QueryResults;
  248. }) {
  249. const fields: Field[] = Array.isArray(props.fields) ? props.fields : [];
  250. let sorts: Sort[] = Array.isArray(props.sorts) ? props.sorts : [];
  251. const team = Array.isArray(props.team) ? props.team : [];
  252. const project = Array.isArray(props.project) ? props.project : [];
  253. const environment = Array.isArray(props.environment) ? props.environment : [];
  254. // only include sort keys that are included in the fields
  255. let equations = 0;
  256. const sortKeys = fields
  257. .map(field => {
  258. if (isEquation(field.field)) {
  259. const sortKey = getSortKeyFromField(
  260. {field: `equation[${equations}]`},
  261. undefined
  262. );
  263. equations += 1;
  264. return sortKey;
  265. }
  266. return getSortKeyFromField(field, undefined);
  267. })
  268. .filter((sortKey): sortKey is string => !!sortKey);
  269. const sort = sorts.find(currentSort => sortKeys.includes(currentSort.field));
  270. sorts = sort ? [sort] : [];
  271. const id = props.id !== null && props.id !== void 0 ? String(props.id) : void 0;
  272. this.id = id;
  273. this.name = props.name;
  274. this.fields = fields;
  275. this.sorts = sorts;
  276. this.query = typeof props.query === 'string' ? props.query : '';
  277. this.team = team;
  278. this.project = project;
  279. this.start = props.start;
  280. this.end = props.end;
  281. this.statsPeriod = props.statsPeriod;
  282. this.environment = environment;
  283. this.yAxis = props.yAxis;
  284. this.display = props.display;
  285. this.interval = props.interval;
  286. this.createdBy = props.createdBy;
  287. this.expired = props.expired;
  288. this.additionalConditions = props.additionalConditions ?? new QueryResults([]);
  289. }
  290. static fromLocation(location: Location): EventView {
  291. const {start, end, statsPeriod} = getParams(location.query);
  292. return new EventView({
  293. id: decodeScalar(location.query.id),
  294. name: decodeScalar(location.query.name),
  295. fields: decodeFields(location),
  296. sorts: decodeSorts(location),
  297. query: decodeQuery(location),
  298. team: decodeTeams(location),
  299. project: decodeProjects(location),
  300. start: decodeScalar(start),
  301. end: decodeScalar(end),
  302. statsPeriod: decodeScalar(statsPeriod),
  303. environment: collectQueryStringByKey(location.query, 'environment'),
  304. yAxis: decodeScalar(location.query.yAxis),
  305. display: decodeScalar(location.query.display),
  306. interval: decodeScalar(location.query.interval),
  307. createdBy: undefined,
  308. additionalConditions: new QueryResults([]),
  309. });
  310. }
  311. static fromNewQueryWithLocation(newQuery: NewQuery, location: Location): EventView {
  312. const query = location.query;
  313. // apply global selection header values from location whenever possible
  314. const environment: string[] =
  315. Array.isArray(newQuery.environment) && newQuery.environment.length > 0
  316. ? newQuery.environment
  317. : collectQueryStringByKey(query, 'environment');
  318. const project: number[] =
  319. Array.isArray(newQuery.projects) && newQuery.projects.length > 0
  320. ? newQuery.projects
  321. : decodeProjects(location);
  322. const saved: NewQuery = {
  323. ...newQuery,
  324. environment,
  325. projects: project,
  326. // datetime selection
  327. start: newQuery.start || decodeScalar(query.start),
  328. end: newQuery.end || decodeScalar(query.end),
  329. range: newQuery.range || decodeScalar(query.statsPeriod),
  330. };
  331. return EventView.fromSavedQuery(saved);
  332. }
  333. static getFields(saved: NewQuery | SavedQuery) {
  334. return saved.fields.map((field, i) => {
  335. const width =
  336. saved.widths && saved.widths[i] ? Number(saved.widths[i]) : COL_WIDTH_UNDEFINED;
  337. return {field, width};
  338. });
  339. }
  340. static fromSavedQuery(saved: NewQuery | SavedQuery): EventView {
  341. const fields = EventView.getFields(saved);
  342. // normalize datetime selection
  343. const {start, end, statsPeriod} = getParams({
  344. start: saved.start,
  345. end: saved.end,
  346. statsPeriod: saved.range,
  347. });
  348. return new EventView({
  349. id: saved.id,
  350. name: saved.name,
  351. fields,
  352. query: queryStringFromSavedQuery(saved),
  353. team: saved.teams ?? [],
  354. project: saved.projects,
  355. start: decodeScalar(start),
  356. end: decodeScalar(end),
  357. statsPeriod: decodeScalar(statsPeriod),
  358. sorts: fromSorts(saved.orderby),
  359. environment: collectQueryStringByKey(
  360. {
  361. environment: saved.environment as string[],
  362. },
  363. 'environment'
  364. ),
  365. yAxis: saved.yAxis,
  366. display: saved.display,
  367. createdBy: saved.createdBy,
  368. expired: saved.expired,
  369. additionalConditions: new QueryResults([]),
  370. });
  371. }
  372. static fromSavedQueryOrLocation(
  373. saved: SavedQuery | undefined,
  374. location: Location
  375. ): EventView {
  376. let fields = decodeFields(location);
  377. const {start, end, statsPeriod} = getParams(location.query);
  378. const id = decodeScalar(location.query.id);
  379. const teams = decodeTeams(location);
  380. const projects = decodeProjects(location);
  381. const sorts = decodeSorts(location);
  382. const environments = collectQueryStringByKey(location.query, 'environment');
  383. if (saved) {
  384. if (fields.length === 0) {
  385. fields = EventView.getFields(saved);
  386. }
  387. return new EventView({
  388. id: id || saved.id,
  389. name: decodeScalar(location.query.name) || saved.name,
  390. fields,
  391. query:
  392. 'query' in location.query
  393. ? decodeQuery(location)
  394. : queryStringFromSavedQuery(saved),
  395. sorts: sorts.length === 0 ? fromSorts(saved.orderby) : sorts,
  396. yAxis: decodeScalar(location.query.yAxis) || saved.yAxis,
  397. display: decodeScalar(location.query.display) || saved.display,
  398. interval: decodeScalar(location.query.interval),
  399. createdBy: saved.createdBy,
  400. expired: saved.expired,
  401. additionalConditions: new QueryResults([]),
  402. // Always read team from location since they can be set by other parts
  403. // of the UI
  404. team: teams,
  405. // Always read project and environment from location since they can
  406. // be set by the GlobalSelectionHeaders.
  407. project: projects,
  408. environment: environments,
  409. start: decodeScalar(start),
  410. end: decodeScalar(end),
  411. statsPeriod: decodeScalar(statsPeriod),
  412. });
  413. }
  414. return EventView.fromLocation(location);
  415. }
  416. isEqualTo(other: EventView): boolean {
  417. const keys = [
  418. 'id',
  419. 'name',
  420. 'query',
  421. 'statsPeriod',
  422. 'fields',
  423. 'sorts',
  424. 'project',
  425. 'environment',
  426. 'yAxis',
  427. 'display',
  428. ];
  429. for (const key of keys) {
  430. const currentValue = this[key];
  431. const otherValue = other[key];
  432. if (!isEqual(currentValue, otherValue)) {
  433. return false;
  434. }
  435. }
  436. // compare datetime selections using moment
  437. const dateTimeKeys = ['start', 'end'];
  438. for (const key of dateTimeKeys) {
  439. const currentValue = this[key];
  440. const otherValue = other[key];
  441. if (currentValue && otherValue) {
  442. const currentDateTime = moment.utc(currentValue);
  443. const othereDateTime = moment.utc(otherValue);
  444. if (!currentDateTime.isSame(othereDateTime)) {
  445. return false;
  446. }
  447. }
  448. }
  449. return true;
  450. }
  451. toNewQuery(): NewQuery {
  452. const orderby = this.sorts.length > 0 ? encodeSorts(this.sorts)[0] : undefined;
  453. const newQuery: NewQuery = {
  454. version: 2,
  455. id: this.id,
  456. name: this.name || '',
  457. fields: this.getFields(),
  458. widths: this.getWidths().map(w => String(w)),
  459. orderby,
  460. query: this.query || '',
  461. projects: this.project,
  462. start: this.start,
  463. end: this.end,
  464. range: this.statsPeriod,
  465. environment: this.environment,
  466. yAxis: this.yAxis,
  467. display: this.display,
  468. };
  469. if (!newQuery.query) {
  470. // if query is an empty string, then it cannot be saved, so we omit it
  471. // from the payload
  472. delete newQuery.query;
  473. }
  474. return newQuery;
  475. }
  476. getGlobalSelection(): GlobalSelection {
  477. return {
  478. projects: this.project as number[],
  479. environments: this.environment as string[],
  480. datetime: {
  481. start: this.start ?? null,
  482. end: this.end ?? null,
  483. period: this.statsPeriod ?? '',
  484. // TODO(tony) Add support for the Use UTC option from
  485. // the global headers, currently, that option is not
  486. // supported and all times are assumed to be UTC
  487. utc: true,
  488. },
  489. };
  490. }
  491. getGlobalSelectionQuery(): Query {
  492. const {
  493. environments: environment,
  494. projects,
  495. datetime: {start, end, period, utc},
  496. } = this.getGlobalSelection();
  497. return {
  498. project: projects.map(proj => proj.toString()),
  499. environment,
  500. utc: utc ? 'true' : 'false',
  501. // since these values are from `getGlobalSelection`
  502. // we know they have type `string | null`
  503. start: (start ?? undefined) as string | undefined,
  504. end: (end ?? undefined) as string | undefined,
  505. // we can't use the ?? operator here as we want to
  506. // convert the empty string to undefined
  507. statsPeriod: period ? period : undefined,
  508. };
  509. }
  510. generateBlankQueryStringObject(): Query {
  511. const output = {
  512. id: undefined,
  513. name: undefined,
  514. field: undefined,
  515. widths: undefined,
  516. sort: undefined,
  517. tag: undefined,
  518. query: undefined,
  519. yAxis: undefined,
  520. display: undefined,
  521. interval: undefined,
  522. };
  523. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  524. output[field] = undefined;
  525. }
  526. return output;
  527. }
  528. generateQueryStringObject(): Query {
  529. const output = {
  530. id: this.id,
  531. name: this.name,
  532. field: this.getFields(),
  533. widths: this.getWidths(),
  534. sort: encodeSorts(this.sorts),
  535. environment: this.environment,
  536. project: this.project,
  537. query: this.query,
  538. yAxis: this.yAxis,
  539. display: this.display,
  540. interval: this.interval,
  541. };
  542. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  543. if (this[field] && this[field].length) {
  544. output[field] = this[field];
  545. }
  546. }
  547. return cloneDeep(output as any);
  548. }
  549. isValid(): boolean {
  550. return this.fields.length > 0;
  551. }
  552. getWidths(): number[] {
  553. return this.fields.map(field => (field.width ? field.width : COL_WIDTH_UNDEFINED));
  554. }
  555. getFields(): string[] {
  556. return this.fields.map(field => field.field);
  557. }
  558. getEquations(): string[] {
  559. return this.fields
  560. .filter(field => isEquation(field.field))
  561. .map(field => getEquation(field.field));
  562. }
  563. getAggregateFields(): Field[] {
  564. return this.fields.filter(
  565. field => isAggregateField(field.field) || isAggregateEquation(field.field)
  566. );
  567. }
  568. hasAggregateField() {
  569. return this.fields.some(field => isAggregateField(field.field));
  570. }
  571. hasIdField() {
  572. return this.fields.some(field => field.field === 'id');
  573. }
  574. numOfColumns(): number {
  575. return this.fields.length;
  576. }
  577. getColumns(): TableColumn<React.ReactText>[] {
  578. return decodeColumnOrder(this.fields);
  579. }
  580. getDays(): number {
  581. const statsPeriod = decodeScalar(this.statsPeriod);
  582. return statsPeriodToDays(statsPeriod, this.start, this.end);
  583. }
  584. clone(): EventView {
  585. // NOTE: We rely on usage of Readonly from TypeScript to ensure we do not mutate
  586. // the attributes of EventView directly. This enables us to quickly
  587. // clone new instances of EventView.
  588. return new EventView({
  589. id: this.id,
  590. name: this.name,
  591. fields: this.fields,
  592. sorts: this.sorts,
  593. query: this.query,
  594. team: this.team,
  595. project: this.project,
  596. start: this.start,
  597. end: this.end,
  598. statsPeriod: this.statsPeriod,
  599. environment: this.environment,
  600. yAxis: this.yAxis,
  601. display: this.display,
  602. interval: this.interval,
  603. expired: this.expired,
  604. createdBy: this.createdBy,
  605. additionalConditions: this.additionalConditions,
  606. });
  607. }
  608. withSorts(sorts: Sort[]): EventView {
  609. const newEventView = this.clone();
  610. const fields = newEventView.fields.map(field => getAggregateAlias(field.field));
  611. newEventView.sorts = sorts.filter(sort => fields.includes(sort.field));
  612. return newEventView;
  613. }
  614. withColumns(columns: Column[]): EventView {
  615. const newEventView = this.clone();
  616. const fields: Field[] = columns
  617. .filter(
  618. col =>
  619. ((col.kind === 'field' || col.kind === FieldValueKind.EQUATION) && col.field) ||
  620. (col.kind === 'function' && col.function[0])
  621. )
  622. .map(col => generateFieldAsString(col))
  623. .map((field, i) => {
  624. // newly added field
  625. if (!newEventView.fields[i]) {
  626. return {field, width: COL_WIDTH_UNDEFINED};
  627. }
  628. // Existing columns that were not re ordered should retain
  629. // their old widths.
  630. const existing = newEventView.fields[i];
  631. const width =
  632. existing.field === field && existing.width !== undefined
  633. ? existing.width
  634. : COL_WIDTH_UNDEFINED;
  635. return {field, width};
  636. });
  637. newEventView.fields = fields;
  638. // Update sorts as sorted fields may have been removed.
  639. if (newEventView.sorts) {
  640. // Filter the sort fields down to those that are still selected.
  641. const sortKeys = fields.map(field => fieldToSort(field, undefined)?.field);
  642. const newSort = newEventView.sorts.filter(
  643. sort => sort && sortKeys.includes(sort.field)
  644. );
  645. // If the sort field was removed, try and find a new sortable column.
  646. if (newSort.length === 0) {
  647. const sortField = fields.find(field => isFieldSortable(field, undefined));
  648. if (sortField) {
  649. newSort.push({field: sortField.field, kind: 'desc'});
  650. }
  651. }
  652. newEventView.sorts = newSort;
  653. }
  654. return newEventView;
  655. }
  656. withNewColumn(newColumn: Column): EventView {
  657. const fieldAsString = generateFieldAsString(newColumn);
  658. const newField: Field = {
  659. field: fieldAsString,
  660. width: COL_WIDTH_UNDEFINED,
  661. };
  662. const newEventView = this.clone();
  663. newEventView.fields = [...newEventView.fields, newField];
  664. return newEventView;
  665. }
  666. withResizedColumn(columnIndex: number, newWidth: number) {
  667. const field = this.fields[columnIndex];
  668. const newEventView = this.clone();
  669. if (!field) {
  670. return newEventView;
  671. }
  672. const updateWidth = field.width !== newWidth;
  673. if (updateWidth) {
  674. const fields = [...newEventView.fields];
  675. fields[columnIndex] = {
  676. ...field,
  677. width: newWidth,
  678. };
  679. newEventView.fields = fields;
  680. }
  681. return newEventView;
  682. }
  683. withUpdatedColumn(
  684. columnIndex: number,
  685. updatedColumn: Column,
  686. tableMeta: MetaType | undefined
  687. ): EventView {
  688. const columnToBeUpdated = this.fields[columnIndex];
  689. const fieldAsString = generateFieldAsString(updatedColumn);
  690. const updateField = columnToBeUpdated.field !== fieldAsString;
  691. if (!updateField) {
  692. return this;
  693. }
  694. // ensure tableMeta is non-empty
  695. tableMeta = validateTableMeta(tableMeta);
  696. const newEventView = this.clone();
  697. const updatedField: Field = {
  698. field: fieldAsString,
  699. width: COL_WIDTH_UNDEFINED,
  700. };
  701. const fields = [...newEventView.fields];
  702. fields[columnIndex] = updatedField;
  703. newEventView.fields = fields;
  704. // if the updated column is one of the sorted columns, we may need to remove
  705. // it from the list of sorts
  706. const needleSortIndex = this.sorts.findIndex(sort =>
  707. isSortEqualToField(sort, columnToBeUpdated, tableMeta)
  708. );
  709. if (needleSortIndex >= 0) {
  710. const needleSort = this.sorts[needleSortIndex];
  711. const numOfColumns = this.fields.reduce((sum, currentField) => {
  712. if (isSortEqualToField(needleSort, currentField, tableMeta)) {
  713. return sum + 1;
  714. }
  715. return sum;
  716. }, 0);
  717. // do not bother deleting the sort key if there are more than one columns
  718. // of it in the table.
  719. if (numOfColumns <= 1) {
  720. if (isFieldSortable(updatedField, tableMeta)) {
  721. // use the current updated field as the sort key
  722. const sort = fieldToSort(updatedField, tableMeta)!;
  723. // preserve the sort kind
  724. sort.kind = needleSort.kind;
  725. const sorts = [...newEventView.sorts];
  726. sorts[needleSortIndex] = sort;
  727. newEventView.sorts = sorts;
  728. } else {
  729. const sorts = [...newEventView.sorts];
  730. sorts.splice(needleSortIndex, 1);
  731. newEventView.sorts = [...new Set(sorts)];
  732. }
  733. }
  734. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  735. // establish a default sort by finding the first sortable field
  736. if (isFieldSortable(updatedField, tableMeta)) {
  737. // use the current updated field as the sort key
  738. const sort = fieldToSort(updatedField, tableMeta)!;
  739. // preserve the sort kind
  740. sort.kind = needleSort.kind;
  741. newEventView.sorts = [sort];
  742. } else {
  743. const sortableFieldIndex = newEventView.fields.findIndex(currentField =>
  744. isFieldSortable(currentField, tableMeta)
  745. );
  746. if (sortableFieldIndex >= 0) {
  747. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  748. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  749. newEventView.sorts = [sort];
  750. }
  751. }
  752. }
  753. }
  754. return newEventView;
  755. }
  756. withDeletedColumn(columnIndex: number, tableMeta: MetaType | undefined): EventView {
  757. // Disallow removal of the orphan column, and check for out-of-bounds
  758. if (this.fields.length <= 1 || this.fields.length <= columnIndex || columnIndex < 0) {
  759. return this;
  760. }
  761. // ensure tableMeta is non-empty
  762. tableMeta = validateTableMeta(tableMeta);
  763. // delete the column
  764. const newEventView = this.clone();
  765. const fields = [...newEventView.fields];
  766. fields.splice(columnIndex, 1);
  767. newEventView.fields = fields;
  768. // Ensure there is at least one auto width column
  769. // To ensure a well formed table results.
  770. const hasAutoIndex = fields.find(field => field.width === COL_WIDTH_UNDEFINED);
  771. if (!hasAutoIndex) {
  772. newEventView.fields[0].width = COL_WIDTH_UNDEFINED;
  773. }
  774. // if the deleted column is one of the sorted columns, we need to remove
  775. // it from the list of sorts
  776. const columnToBeDeleted = this.fields[columnIndex];
  777. const needleSortIndex = this.sorts.findIndex(sort =>
  778. isSortEqualToField(sort, columnToBeDeleted, tableMeta)
  779. );
  780. if (needleSortIndex >= 0) {
  781. const needleSort = this.sorts[needleSortIndex];
  782. const numOfColumns = this.fields.reduce((sum, field) => {
  783. if (isSortEqualToField(needleSort, field, tableMeta)) {
  784. return sum + 1;
  785. }
  786. return sum;
  787. }, 0);
  788. // do not bother deleting the sort key if there are more than one columns
  789. // of it in the table.
  790. if (numOfColumns <= 1) {
  791. const sorts = [...newEventView.sorts];
  792. sorts.splice(needleSortIndex, 1);
  793. newEventView.sorts = [...new Set(sorts)];
  794. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  795. // establish a default sort by finding the first sortable field
  796. const sortableFieldIndex = newEventView.fields.findIndex(field =>
  797. isFieldSortable(field, tableMeta)
  798. );
  799. if (sortableFieldIndex >= 0) {
  800. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  801. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  802. newEventView.sorts = [sort];
  803. }
  804. }
  805. }
  806. }
  807. return newEventView;
  808. }
  809. withTeams(teams: ('myteams' | number)[]): EventView {
  810. const newEventView = this.clone();
  811. newEventView.team = teams;
  812. return newEventView;
  813. }
  814. getSorts(): TableColumnSort<React.ReactText>[] {
  815. return this.sorts.map(
  816. sort =>
  817. ({
  818. key: sort.field,
  819. order: sort.kind,
  820. } as TableColumnSort<string>)
  821. );
  822. }
  823. // returns query input for the search
  824. getQuery(inputQuery: string | string[] | null | undefined): string {
  825. const queryParts: string[] = [];
  826. if (this.query) {
  827. if (this.additionalConditions) {
  828. queryParts.push(this.getQueryWithAdditionalConditions());
  829. } else {
  830. queryParts.push(this.query);
  831. }
  832. }
  833. if (inputQuery) {
  834. // there may be duplicate query in the query string
  835. // e.g. query=hello&query=world
  836. if (Array.isArray(inputQuery)) {
  837. inputQuery.forEach(query => {
  838. if (typeof query === 'string' && !queryParts.includes(query)) {
  839. queryParts.push(query);
  840. }
  841. });
  842. }
  843. if (typeof inputQuery === 'string' && !queryParts.includes(inputQuery)) {
  844. queryParts.push(inputQuery);
  845. }
  846. }
  847. return queryParts.join(' ');
  848. }
  849. getFacetsAPIPayload(
  850. location: Location
  851. ): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
  852. const payload = this.getEventsAPIPayload(location);
  853. const remove = [
  854. 'id',
  855. 'name',
  856. 'per_page',
  857. 'sort',
  858. 'cursor',
  859. 'field',
  860. 'equation',
  861. 'interval',
  862. ];
  863. for (const key of remove) {
  864. delete payload[key];
  865. }
  866. return payload;
  867. }
  868. normalizeDateSelection(location: Location) {
  869. const query = (location && location.query) || {};
  870. // pick only the query strings that we care about
  871. const picked = pickRelevantLocationQueryStrings(location);
  872. const hasDateSelection = this.statsPeriod || (this.start && this.end);
  873. // an eventview's date selection has higher precedence than the date selection in the query string
  874. const dateSelection = hasDateSelection
  875. ? {
  876. start: this.start,
  877. end: this.end,
  878. statsPeriod: this.statsPeriod,
  879. }
  880. : {
  881. start: picked.start,
  882. end: picked.end,
  883. period: decodeScalar(query.period),
  884. statsPeriod: picked.statsPeriod,
  885. };
  886. // normalize datetime selection
  887. return getParams({
  888. ...dateSelection,
  889. utc: decodeScalar(query.utc),
  890. });
  891. }
  892. // Takes an EventView instance and converts it into the format required for the events API
  893. getEventsAPIPayload(location: Location): EventQuery & LocationQuery {
  894. // pick only the query strings that we care about
  895. const picked = pickRelevantLocationQueryStrings(location);
  896. // normalize datetime selection
  897. const normalizedTimeWindowParams = this.normalizeDateSelection(location);
  898. const sort =
  899. this.sorts.length <= 0
  900. ? undefined
  901. : this.sorts.length > 1
  902. ? encodeSorts(this.sorts)
  903. : encodeSort(this.sorts[0]);
  904. const fields = this.getFields();
  905. const team = this.team.map(proj => String(proj));
  906. const project = this.project.map(proj => String(proj));
  907. const environment = this.environment as string[];
  908. // generate event query
  909. const eventQuery = Object.assign(
  910. omit(picked, DATETIME_QUERY_STRING_KEYS),
  911. normalizedTimeWindowParams,
  912. {
  913. team,
  914. project,
  915. environment,
  916. field: [...new Set(fields)],
  917. sort,
  918. per_page: DEFAULT_PER_PAGE,
  919. query: this.getQueryWithAdditionalConditions(),
  920. }
  921. ) as EventQuery & LocationQuery;
  922. if (eventQuery.team && !eventQuery.team.length) {
  923. delete eventQuery.team;
  924. }
  925. if (!eventQuery.sort) {
  926. delete eventQuery.sort;
  927. }
  928. return eventQuery;
  929. }
  930. getResultsViewUrlTarget(slug: string): {pathname: string; query: Query} {
  931. return {
  932. pathname: `/organizations/${slug}/discover/results/`,
  933. query: this.generateQueryStringObject(),
  934. };
  935. }
  936. getResultsViewShortUrlTarget(slug: string): {pathname: string; query: Query} {
  937. const output = {id: this.id};
  938. for (const field of [...Object.values(URL_PARAM), 'cursor']) {
  939. if (this[field] && this[field].length) {
  940. output[field] = this[field];
  941. }
  942. }
  943. return {
  944. pathname: `/organizations/${slug}/discover/results/`,
  945. query: cloneDeep(output as any),
  946. };
  947. }
  948. getPerformanceTransactionEventsViewUrlTarget(
  949. slug: string,
  950. options: {
  951. showTransactions?: EventsDisplayFilterName;
  952. breakdown?: SpanOperationBreakdownFilter;
  953. webVital?: WebVital;
  954. }
  955. ): {pathname: string; query: Query} {
  956. const {showTransactions, breakdown, webVital} = options;
  957. const output = {
  958. sort: encodeSorts(this.sorts),
  959. project: this.project,
  960. query: this.query,
  961. transaction: this.name,
  962. showTransactions,
  963. breakdown,
  964. webVital,
  965. };
  966. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  967. if (this[field] && this[field].length) {
  968. output[field] = this[field];
  969. }
  970. }
  971. const query = cloneDeep(output as any);
  972. return {
  973. pathname: `/organizations/${slug}/performance/summary/events/`,
  974. query,
  975. };
  976. }
  977. sortForField(field: Field, tableMeta: MetaType | undefined): Sort | undefined {
  978. if (!tableMeta) {
  979. return undefined;
  980. }
  981. return this.sorts.find(sort => isSortEqualToField(sort, field, tableMeta));
  982. }
  983. sortOnField(field: Field, tableMeta: MetaType, kind?: 'desc' | 'asc'): EventView {
  984. // check if field can be sorted
  985. if (!isFieldSortable(field, tableMeta)) {
  986. return this;
  987. }
  988. const needleIndex = this.sorts.findIndex(sort =>
  989. isSortEqualToField(sort, field, tableMeta)
  990. );
  991. if (needleIndex >= 0) {
  992. const newEventView = this.clone();
  993. const currentSort = this.sorts[needleIndex];
  994. const sorts = [...newEventView.sorts];
  995. sorts[needleIndex] = kind
  996. ? setSortOrder(currentSort, kind)
  997. : reverseSort(currentSort);
  998. newEventView.sorts = sorts;
  999. return newEventView;
  1000. }
  1001. // field is currently not sorted; so, we sort on it
  1002. const newEventView = this.clone();
  1003. // invariant: this is not falsey, since sortKey exists
  1004. const sort = fieldToSort(field, tableMeta, kind)!;
  1005. newEventView.sorts = [sort];
  1006. return newEventView;
  1007. }
  1008. getYAxisOptions(): SelectValue<string>[] {
  1009. // Make option set and add the default options in.
  1010. return uniqBy(
  1011. this.getAggregateFields()
  1012. // Only include aggregates that make sense to be graphable (eg. not string or date)
  1013. .filter(
  1014. (field: Field) =>
  1015. isLegalYAxisType(aggregateOutputType(field.field)) ||
  1016. isAggregateEquation(field.field)
  1017. )
  1018. .map((field: Field) => ({
  1019. label: isEquation(field.field) ? getEquation(field.field) : field.field,
  1020. value: field.field,
  1021. }))
  1022. .concat(CHART_AXIS_OPTIONS),
  1023. 'value'
  1024. );
  1025. }
  1026. getYAxis(): string {
  1027. const yAxisOptions = this.getYAxisOptions();
  1028. const yAxis = this.yAxis;
  1029. const defaultOption = yAxisOptions[0].value;
  1030. if (!yAxis) {
  1031. return defaultOption;
  1032. }
  1033. // ensure current selected yAxis is one of the items in yAxisOptions
  1034. const result = yAxisOptions.findIndex(
  1035. (option: SelectValue<string>) => option.value === yAxis
  1036. );
  1037. if (result >= 0) {
  1038. return yAxis;
  1039. }
  1040. return defaultOption;
  1041. }
  1042. getDisplayOptions(): SelectValue<string>[] {
  1043. return DISPLAY_MODE_OPTIONS.map(item => {
  1044. if (item.value === DisplayModes.PREVIOUS) {
  1045. if (this.start || this.end) {
  1046. return {...item, disabled: true};
  1047. }
  1048. }
  1049. if (item.value === DisplayModes.TOP5 || item.value === DisplayModes.DAILYTOP5) {
  1050. if (this.getAggregateFields().length === 0) {
  1051. return {
  1052. ...item,
  1053. disabled: true,
  1054. tooltip: t('Add a function that groups events to use this view.'),
  1055. };
  1056. }
  1057. }
  1058. if (item.value === DisplayModes.DAILY || item.value === DisplayModes.DAILYTOP5) {
  1059. if (this.getDays() < 1) {
  1060. return {
  1061. ...item,
  1062. disabled: true,
  1063. tooltip: t('Change the date rage to at least 1 day to use this view.'),
  1064. };
  1065. }
  1066. }
  1067. return item;
  1068. });
  1069. }
  1070. getDisplayMode() {
  1071. const mode = this.display ?? DisplayModes.DEFAULT;
  1072. const displayOptions = this.getDisplayOptions();
  1073. let display = (Object.values(DisplayModes) as string[]).includes(mode)
  1074. ? mode
  1075. : DisplayModes.DEFAULT;
  1076. const cond = option => option.value === display;
  1077. // Just in case we define a fallback chain that results in an infinite loop.
  1078. // The number 5 isn't anything special, its just larger than the longest fallback
  1079. // chain that exists and isn't too big.
  1080. for (let i = 0; i < 5; i++) {
  1081. const selectedOption = displayOptions.find(cond);
  1082. if (selectedOption && !selectedOption.disabled) {
  1083. return display;
  1084. }
  1085. display = DISPLAY_MODE_FALLBACK_OPTIONS[display];
  1086. }
  1087. // after trying to find an enabled display mode and failing to find one,
  1088. // we just use the default display mode
  1089. return DisplayModes.DEFAULT;
  1090. }
  1091. getQueryWithAdditionalConditions() {
  1092. const {query} = this;
  1093. if (this.additionalConditions.isEmpty()) {
  1094. return query;
  1095. }
  1096. const conditions = tokenizeSearch(query);
  1097. Object.entries(this.additionalConditions.tagValues).forEach(([tag, tagValues]) => {
  1098. conditions.addTagValues(tag, tagValues);
  1099. });
  1100. return conditions.formatString();
  1101. }
  1102. }
  1103. const isFieldsSimilar = (
  1104. currentValue: Array<string>,
  1105. otherValue: Array<string>
  1106. ): boolean => {
  1107. // For equation's their order matters because we alias them based on index
  1108. const currentEquations = currentValue.filter(isEquation);
  1109. const otherEquations = otherValue.filter(isEquation);
  1110. // Field orders don't matter, so using a set for comparison
  1111. const currentFields = new Set(currentValue.filter(value => !isEquation(value)));
  1112. const otherFields = new Set(otherValue.filter(value => !isEquation(value)));
  1113. if (!isEqual(currentEquations, otherEquations)) {
  1114. return false;
  1115. } else if (!isEqual(currentFields, otherFields)) {
  1116. return false;
  1117. }
  1118. return true;
  1119. };
  1120. export const isAPIPayloadSimilar = (
  1121. current: EventQuery & LocationQuery,
  1122. other: EventQuery & LocationQuery
  1123. ): boolean => {
  1124. const currentKeys = new Set(Object.keys(current));
  1125. const otherKeys = new Set(Object.keys(other));
  1126. if (!isEqual(currentKeys, otherKeys)) {
  1127. return false;
  1128. }
  1129. for (const key of currentKeys) {
  1130. const currentValue = current[key];
  1131. const otherValue = other[key];
  1132. if (key === 'field') {
  1133. if (!isFieldsSimilar(currentValue, otherValue)) {
  1134. return false;
  1135. }
  1136. } else {
  1137. const currentTarget = Array.isArray(currentValue)
  1138. ? new Set(currentValue)
  1139. : currentValue;
  1140. const otherTarget = Array.isArray(otherValue) ? new Set(otherValue) : otherValue;
  1141. if (!isEqual(currentTarget, otherTarget)) {
  1142. return false;
  1143. }
  1144. }
  1145. }
  1146. return true;
  1147. };
  1148. export function pickRelevantLocationQueryStrings(location: Location) {
  1149. const query = location.query || {};
  1150. const picked = pick(query || {}, EXTERNAL_QUERY_STRING_KEYS);
  1151. return picked;
  1152. }
  1153. export default EventView;