eventView.tsx 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365
  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 (field.field && 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
  289. ? props.additionalConditions.copy()
  290. : new QueryResults([]);
  291. }
  292. static fromLocation(location: Location): EventView {
  293. const {start, end, statsPeriod} = getParams(location.query);
  294. return new EventView({
  295. id: decodeScalar(location.query.id),
  296. name: decodeScalar(location.query.name),
  297. fields: decodeFields(location),
  298. sorts: decodeSorts(location),
  299. query: decodeQuery(location),
  300. team: decodeTeams(location),
  301. project: decodeProjects(location),
  302. start: decodeScalar(start),
  303. end: decodeScalar(end),
  304. statsPeriod: decodeScalar(statsPeriod),
  305. environment: collectQueryStringByKey(location.query, 'environment'),
  306. yAxis: decodeScalar(location.query.yAxis),
  307. display: decodeScalar(location.query.display),
  308. interval: decodeScalar(location.query.interval),
  309. createdBy: undefined,
  310. additionalConditions: new QueryResults([]),
  311. });
  312. }
  313. static fromNewQueryWithLocation(newQuery: NewQuery, location: Location): EventView {
  314. const query = location.query;
  315. // apply global selection header values from location whenever possible
  316. const environment: string[] =
  317. Array.isArray(newQuery.environment) && newQuery.environment.length > 0
  318. ? newQuery.environment
  319. : collectQueryStringByKey(query, 'environment');
  320. const project: number[] =
  321. Array.isArray(newQuery.projects) && newQuery.projects.length > 0
  322. ? newQuery.projects
  323. : decodeProjects(location);
  324. const saved: NewQuery = {
  325. ...newQuery,
  326. environment,
  327. projects: project,
  328. // datetime selection
  329. start: newQuery.start || decodeScalar(query.start),
  330. end: newQuery.end || decodeScalar(query.end),
  331. range: newQuery.range || decodeScalar(query.statsPeriod),
  332. };
  333. return EventView.fromSavedQuery(saved);
  334. }
  335. static getFields(saved: NewQuery | SavedQuery) {
  336. return saved.fields.map((field, i) => {
  337. const width =
  338. saved.widths && saved.widths[i] ? Number(saved.widths[i]) : COL_WIDTH_UNDEFINED;
  339. return {field, width};
  340. });
  341. }
  342. static fromSavedQuery(saved: NewQuery | SavedQuery): EventView {
  343. const fields = EventView.getFields(saved);
  344. // normalize datetime selection
  345. const {start, end, statsPeriod} = getParams({
  346. start: saved.start,
  347. end: saved.end,
  348. statsPeriod: saved.range,
  349. });
  350. return new EventView({
  351. id: saved.id,
  352. name: saved.name,
  353. fields,
  354. query: queryStringFromSavedQuery(saved),
  355. team: saved.teams ?? [],
  356. project: saved.projects,
  357. start: decodeScalar(start),
  358. end: decodeScalar(end),
  359. statsPeriod: decodeScalar(statsPeriod),
  360. sorts: fromSorts(saved.orderby),
  361. environment: collectQueryStringByKey(
  362. {
  363. environment: saved.environment as string[],
  364. },
  365. 'environment'
  366. ),
  367. yAxis: saved.yAxis,
  368. display: saved.display,
  369. createdBy: saved.createdBy,
  370. expired: saved.expired,
  371. additionalConditions: new QueryResults([]),
  372. });
  373. }
  374. static fromSavedQueryOrLocation(
  375. saved: SavedQuery | undefined,
  376. location: Location
  377. ): EventView {
  378. let fields = decodeFields(location);
  379. const {start, end, statsPeriod} = getParams(location.query);
  380. const id = decodeScalar(location.query.id);
  381. const teams = decodeTeams(location);
  382. const projects = decodeProjects(location);
  383. const sorts = decodeSorts(location);
  384. const environments = collectQueryStringByKey(location.query, 'environment');
  385. if (saved) {
  386. if (fields.length === 0) {
  387. fields = EventView.getFields(saved);
  388. }
  389. return new EventView({
  390. id: id || saved.id,
  391. name: decodeScalar(location.query.name) || saved.name,
  392. fields,
  393. query:
  394. 'query' in location.query
  395. ? decodeQuery(location)
  396. : queryStringFromSavedQuery(saved),
  397. sorts: sorts.length === 0 ? fromSorts(saved.orderby) : sorts,
  398. yAxis: decodeScalar(location.query.yAxis) || saved.yAxis,
  399. display: decodeScalar(location.query.display) || saved.display,
  400. interval: decodeScalar(location.query.interval),
  401. createdBy: saved.createdBy,
  402. expired: saved.expired,
  403. additionalConditions: new QueryResults([]),
  404. // Always read team from location since they can be set by other parts
  405. // of the UI
  406. team: teams,
  407. // Always read project and environment from location since they can
  408. // be set by the GlobalSelectionHeaders.
  409. project: projects,
  410. environment: environments,
  411. start: decodeScalar(start),
  412. end: decodeScalar(end),
  413. statsPeriod: decodeScalar(statsPeriod),
  414. });
  415. }
  416. return EventView.fromLocation(location);
  417. }
  418. isEqualTo(other: EventView): boolean {
  419. const keys = [
  420. 'id',
  421. 'name',
  422. 'query',
  423. 'statsPeriod',
  424. 'fields',
  425. 'sorts',
  426. 'project',
  427. 'environment',
  428. 'yAxis',
  429. 'display',
  430. ];
  431. for (const key of keys) {
  432. const currentValue = this[key];
  433. const otherValue = other[key];
  434. if (!isEqual(currentValue, otherValue)) {
  435. return false;
  436. }
  437. }
  438. // compare datetime selections using moment
  439. const dateTimeKeys = ['start', 'end'];
  440. for (const key of dateTimeKeys) {
  441. const currentValue = this[key];
  442. const otherValue = other[key];
  443. if (currentValue && otherValue) {
  444. const currentDateTime = moment.utc(currentValue);
  445. const othereDateTime = moment.utc(otherValue);
  446. if (!currentDateTime.isSame(othereDateTime)) {
  447. return false;
  448. }
  449. }
  450. }
  451. return true;
  452. }
  453. toNewQuery(): NewQuery {
  454. const orderby = this.sorts.length > 0 ? encodeSorts(this.sorts)[0] : undefined;
  455. const newQuery: NewQuery = {
  456. version: 2,
  457. id: this.id,
  458. name: this.name || '',
  459. fields: this.getFields(),
  460. widths: this.getWidths().map(w => String(w)),
  461. orderby,
  462. query: this.query || '',
  463. projects: this.project,
  464. start: this.start,
  465. end: this.end,
  466. range: this.statsPeriod,
  467. environment: this.environment,
  468. yAxis: this.yAxis,
  469. display: this.display,
  470. };
  471. if (!newQuery.query) {
  472. // if query is an empty string, then it cannot be saved, so we omit it
  473. // from the payload
  474. delete newQuery.query;
  475. }
  476. return newQuery;
  477. }
  478. getGlobalSelection(): GlobalSelection {
  479. return {
  480. projects: this.project as number[],
  481. environments: this.environment as string[],
  482. datetime: {
  483. start: this.start ?? null,
  484. end: this.end ?? null,
  485. period: this.statsPeriod ?? '',
  486. // TODO(tony) Add support for the Use UTC option from
  487. // the global headers, currently, that option is not
  488. // supported and all times are assumed to be UTC
  489. utc: true,
  490. },
  491. };
  492. }
  493. getGlobalSelectionQuery(): Query {
  494. const {
  495. environments: environment,
  496. projects,
  497. datetime: {start, end, period, utc},
  498. } = this.getGlobalSelection();
  499. return {
  500. project: projects.map(proj => proj.toString()),
  501. environment,
  502. utc: utc ? 'true' : 'false',
  503. // since these values are from `getGlobalSelection`
  504. // we know they have type `string | null`
  505. start: (start ?? undefined) as string | undefined,
  506. end: (end ?? undefined) as string | undefined,
  507. // we can't use the ?? operator here as we want to
  508. // convert the empty string to undefined
  509. statsPeriod: period ? period : undefined,
  510. };
  511. }
  512. generateBlankQueryStringObject(): Query {
  513. const output = {
  514. id: undefined,
  515. name: undefined,
  516. field: undefined,
  517. widths: undefined,
  518. sort: undefined,
  519. tag: undefined,
  520. query: undefined,
  521. yAxis: undefined,
  522. display: undefined,
  523. interval: undefined,
  524. };
  525. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  526. output[field] = undefined;
  527. }
  528. return output;
  529. }
  530. generateQueryStringObject(): Query {
  531. const output = {
  532. id: this.id,
  533. name: this.name,
  534. field: this.getFields(),
  535. widths: this.getWidths(),
  536. sort: encodeSorts(this.sorts),
  537. environment: this.environment,
  538. project: this.project,
  539. query: this.query,
  540. yAxis: this.yAxis,
  541. display: this.display,
  542. interval: this.interval,
  543. };
  544. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  545. if (this[field] && this[field].length) {
  546. output[field] = this[field];
  547. }
  548. }
  549. return cloneDeep(output as any);
  550. }
  551. isValid(): boolean {
  552. return this.fields.length > 0;
  553. }
  554. getWidths(): number[] {
  555. return this.fields.map(field => (field.width ? field.width : COL_WIDTH_UNDEFINED));
  556. }
  557. getFields(): string[] {
  558. return this.fields.map(field => field.field);
  559. }
  560. getEquations(): string[] {
  561. return this.fields
  562. .filter(field => isEquation(field.field))
  563. .map(field => getEquation(field.field));
  564. }
  565. getAggregateFields(): Field[] {
  566. return this.fields.filter(
  567. field => isAggregateField(field.field) || isAggregateEquation(field.field)
  568. );
  569. }
  570. hasAggregateField() {
  571. return this.fields.some(field => isAggregateField(field.field));
  572. }
  573. hasIdField() {
  574. return this.fields.some(field => field.field === 'id');
  575. }
  576. numOfColumns(): number {
  577. return this.fields.length;
  578. }
  579. getColumns(): TableColumn<React.ReactText>[] {
  580. return decodeColumnOrder(this.fields);
  581. }
  582. getDays(): number {
  583. const statsPeriod = decodeScalar(this.statsPeriod);
  584. return statsPeriodToDays(statsPeriod, this.start, this.end);
  585. }
  586. clone(): EventView {
  587. // NOTE: We rely on usage of Readonly from TypeScript to ensure we do not mutate
  588. // the attributes of EventView directly. This enables us to quickly
  589. // clone new instances of EventView.
  590. return new EventView({
  591. id: this.id,
  592. name: this.name,
  593. fields: this.fields,
  594. sorts: this.sorts,
  595. query: this.query,
  596. team: this.team,
  597. project: this.project,
  598. start: this.start,
  599. end: this.end,
  600. statsPeriod: this.statsPeriod,
  601. environment: this.environment,
  602. yAxis: this.yAxis,
  603. display: this.display,
  604. interval: this.interval,
  605. expired: this.expired,
  606. createdBy: this.createdBy,
  607. additionalConditions: this.additionalConditions,
  608. });
  609. }
  610. withSorts(sorts: Sort[]): EventView {
  611. const newEventView = this.clone();
  612. const fields = newEventView.fields.map(field => getAggregateAlias(field.field));
  613. newEventView.sorts = sorts.filter(sort => fields.includes(sort.field));
  614. return newEventView;
  615. }
  616. withColumns(columns: Column[]): EventView {
  617. const newEventView = this.clone();
  618. const fields: Field[] = columns
  619. .filter(
  620. col =>
  621. ((col.kind === 'field' || col.kind === FieldValueKind.EQUATION) && col.field) ||
  622. (col.kind === 'function' && col.function[0])
  623. )
  624. .map(col => generateFieldAsString(col))
  625. .map((field, i) => {
  626. // newly added field
  627. if (!newEventView.fields[i]) {
  628. return {field, width: COL_WIDTH_UNDEFINED};
  629. }
  630. // Existing columns that were not re ordered should retain
  631. // their old widths.
  632. const existing = newEventView.fields[i];
  633. const width =
  634. existing.field === field && existing.width !== undefined
  635. ? existing.width
  636. : COL_WIDTH_UNDEFINED;
  637. return {field, width};
  638. });
  639. newEventView.fields = fields;
  640. // Update sorts as sorted fields may have been removed.
  641. if (newEventView.sorts) {
  642. // Filter the sort fields down to those that are still selected.
  643. const sortKeys = fields.map(field => fieldToSort(field, undefined)?.field);
  644. const newSort = newEventView.sorts.filter(
  645. sort => sort && sortKeys.includes(sort.field)
  646. );
  647. // If the sort field was removed, try and find a new sortable column.
  648. if (newSort.length === 0) {
  649. const sortField = fields.find(field => isFieldSortable(field, undefined));
  650. if (sortField) {
  651. newSort.push({field: sortField.field, kind: 'desc'});
  652. }
  653. }
  654. newEventView.sorts = newSort;
  655. }
  656. return newEventView;
  657. }
  658. withNewColumn(newColumn: Column): EventView {
  659. const fieldAsString = generateFieldAsString(newColumn);
  660. const newField: Field = {
  661. field: fieldAsString,
  662. width: COL_WIDTH_UNDEFINED,
  663. };
  664. const newEventView = this.clone();
  665. newEventView.fields = [...newEventView.fields, newField];
  666. return newEventView;
  667. }
  668. withResizedColumn(columnIndex: number, newWidth: number) {
  669. const field = this.fields[columnIndex];
  670. const newEventView = this.clone();
  671. if (!field) {
  672. return newEventView;
  673. }
  674. const updateWidth = field.width !== newWidth;
  675. if (updateWidth) {
  676. const fields = [...newEventView.fields];
  677. fields[columnIndex] = {
  678. ...field,
  679. width: newWidth,
  680. };
  681. newEventView.fields = fields;
  682. }
  683. return newEventView;
  684. }
  685. withUpdatedColumn(
  686. columnIndex: number,
  687. updatedColumn: Column,
  688. tableMeta: MetaType | undefined
  689. ): EventView {
  690. const columnToBeUpdated = this.fields[columnIndex];
  691. const fieldAsString = generateFieldAsString(updatedColumn);
  692. const updateField = columnToBeUpdated.field !== fieldAsString;
  693. if (!updateField) {
  694. return this;
  695. }
  696. // ensure tableMeta is non-empty
  697. tableMeta = validateTableMeta(tableMeta);
  698. const newEventView = this.clone();
  699. const updatedField: Field = {
  700. field: fieldAsString,
  701. width: COL_WIDTH_UNDEFINED,
  702. };
  703. const fields = [...newEventView.fields];
  704. fields[columnIndex] = updatedField;
  705. newEventView.fields = fields;
  706. // if the updated column is one of the sorted columns, we may need to remove
  707. // it from the list of sorts
  708. const needleSortIndex = this.sorts.findIndex(sort =>
  709. isSortEqualToField(sort, columnToBeUpdated, tableMeta)
  710. );
  711. if (needleSortIndex >= 0) {
  712. const needleSort = this.sorts[needleSortIndex];
  713. const numOfColumns = this.fields.reduce((sum, currentField) => {
  714. if (isSortEqualToField(needleSort, currentField, tableMeta)) {
  715. return sum + 1;
  716. }
  717. return sum;
  718. }, 0);
  719. // do not bother deleting the sort key if there are more than one columns
  720. // of it in the table.
  721. if (numOfColumns <= 1) {
  722. if (isFieldSortable(updatedField, tableMeta)) {
  723. // use the current updated field as the sort key
  724. const sort = fieldToSort(updatedField, tableMeta)!;
  725. // preserve the sort kind
  726. sort.kind = needleSort.kind;
  727. const sorts = [...newEventView.sorts];
  728. sorts[needleSortIndex] = sort;
  729. newEventView.sorts = sorts;
  730. } else {
  731. const sorts = [...newEventView.sorts];
  732. sorts.splice(needleSortIndex, 1);
  733. newEventView.sorts = [...new Set(sorts)];
  734. }
  735. }
  736. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  737. // establish a default sort by finding the first sortable field
  738. if (isFieldSortable(updatedField, tableMeta)) {
  739. // use the current updated field as the sort key
  740. const sort = fieldToSort(updatedField, tableMeta)!;
  741. // preserve the sort kind
  742. sort.kind = needleSort.kind;
  743. newEventView.sorts = [sort];
  744. } else {
  745. const sortableFieldIndex = newEventView.fields.findIndex(currentField =>
  746. isFieldSortable(currentField, tableMeta)
  747. );
  748. if (sortableFieldIndex >= 0) {
  749. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  750. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  751. newEventView.sorts = [sort];
  752. }
  753. }
  754. }
  755. }
  756. return newEventView;
  757. }
  758. withDeletedColumn(columnIndex: number, tableMeta: MetaType | undefined): EventView {
  759. // Disallow removal of the orphan column, and check for out-of-bounds
  760. if (this.fields.length <= 1 || this.fields.length <= columnIndex || columnIndex < 0) {
  761. return this;
  762. }
  763. // ensure tableMeta is non-empty
  764. tableMeta = validateTableMeta(tableMeta);
  765. // delete the column
  766. const newEventView = this.clone();
  767. const fields = [...newEventView.fields];
  768. fields.splice(columnIndex, 1);
  769. newEventView.fields = fields;
  770. // Ensure there is at least one auto width column
  771. // To ensure a well formed table results.
  772. const hasAutoIndex = fields.find(field => field.width === COL_WIDTH_UNDEFINED);
  773. if (!hasAutoIndex) {
  774. newEventView.fields[0].width = COL_WIDTH_UNDEFINED;
  775. }
  776. // if the deleted column is one of the sorted columns, we need to remove
  777. // it from the list of sorts
  778. const columnToBeDeleted = this.fields[columnIndex];
  779. const needleSortIndex = this.sorts.findIndex(sort =>
  780. isSortEqualToField(sort, columnToBeDeleted, tableMeta)
  781. );
  782. if (needleSortIndex >= 0) {
  783. const needleSort = this.sorts[needleSortIndex];
  784. const numOfColumns = this.fields.reduce((sum, field) => {
  785. if (isSortEqualToField(needleSort, field, tableMeta)) {
  786. return sum + 1;
  787. }
  788. return sum;
  789. }, 0);
  790. // do not bother deleting the sort key if there are more than one columns
  791. // of it in the table.
  792. if (numOfColumns <= 1) {
  793. const sorts = [...newEventView.sorts];
  794. sorts.splice(needleSortIndex, 1);
  795. newEventView.sorts = [...new Set(sorts)];
  796. if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
  797. // establish a default sort by finding the first sortable field
  798. const sortableFieldIndex = newEventView.fields.findIndex(field =>
  799. isFieldSortable(field, tableMeta)
  800. );
  801. if (sortableFieldIndex >= 0) {
  802. const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
  803. const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
  804. newEventView.sorts = [sort];
  805. }
  806. }
  807. }
  808. }
  809. return newEventView;
  810. }
  811. withTeams(teams: ('myteams' | number)[]): EventView {
  812. const newEventView = this.clone();
  813. newEventView.team = teams;
  814. return newEventView;
  815. }
  816. getSorts(): TableColumnSort<React.ReactText>[] {
  817. return this.sorts.map(
  818. sort =>
  819. ({
  820. key: sort.field,
  821. order: sort.kind,
  822. } as TableColumnSort<string>)
  823. );
  824. }
  825. // returns query input for the search
  826. getQuery(inputQuery: string | string[] | null | undefined): string {
  827. const queryParts: string[] = [];
  828. if (this.query) {
  829. if (this.additionalConditions) {
  830. queryParts.push(this.getQueryWithAdditionalConditions());
  831. } else {
  832. queryParts.push(this.query);
  833. }
  834. }
  835. if (inputQuery) {
  836. // there may be duplicate query in the query string
  837. // e.g. query=hello&query=world
  838. if (Array.isArray(inputQuery)) {
  839. inputQuery.forEach(query => {
  840. if (typeof query === 'string' && !queryParts.includes(query)) {
  841. queryParts.push(query);
  842. }
  843. });
  844. }
  845. if (typeof inputQuery === 'string' && !queryParts.includes(inputQuery)) {
  846. queryParts.push(inputQuery);
  847. }
  848. }
  849. return queryParts.join(' ');
  850. }
  851. getFacetsAPIPayload(
  852. location: Location
  853. ): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
  854. const payload = this.getEventsAPIPayload(location);
  855. const remove = [
  856. 'id',
  857. 'name',
  858. 'per_page',
  859. 'sort',
  860. 'cursor',
  861. 'field',
  862. 'equation',
  863. 'interval',
  864. ];
  865. for (const key of remove) {
  866. delete payload[key];
  867. }
  868. return payload;
  869. }
  870. normalizeDateSelection(location: Location) {
  871. const query = (location && location.query) || {};
  872. // pick only the query strings that we care about
  873. const picked = pickRelevantLocationQueryStrings(location);
  874. const hasDateSelection = this.statsPeriod || (this.start && this.end);
  875. // an eventview's date selection has higher precedence than the date selection in the query string
  876. const dateSelection = hasDateSelection
  877. ? {
  878. start: this.start,
  879. end: this.end,
  880. statsPeriod: this.statsPeriod,
  881. }
  882. : {
  883. start: picked.start,
  884. end: picked.end,
  885. period: decodeScalar(query.period),
  886. statsPeriod: picked.statsPeriod,
  887. };
  888. // normalize datetime selection
  889. return getParams({
  890. ...dateSelection,
  891. utc: decodeScalar(query.utc),
  892. });
  893. }
  894. // Takes an EventView instance and converts it into the format required for the events API
  895. getEventsAPIPayload(location: Location): EventQuery & LocationQuery {
  896. // pick only the query strings that we care about
  897. const picked = pickRelevantLocationQueryStrings(location);
  898. // normalize datetime selection
  899. const normalizedTimeWindowParams = this.normalizeDateSelection(location);
  900. const sort =
  901. this.sorts.length <= 0
  902. ? undefined
  903. : this.sorts.length > 1
  904. ? encodeSorts(this.sorts)
  905. : encodeSort(this.sorts[0]);
  906. const fields = this.getFields();
  907. const team = this.team.map(proj => String(proj));
  908. const project = this.project.map(proj => String(proj));
  909. const environment = this.environment as string[];
  910. // generate event query
  911. const eventQuery = Object.assign(
  912. omit(picked, DATETIME_QUERY_STRING_KEYS),
  913. normalizedTimeWindowParams,
  914. {
  915. team,
  916. project,
  917. environment,
  918. field: [...new Set(fields)],
  919. sort,
  920. per_page: DEFAULT_PER_PAGE,
  921. query: this.getQueryWithAdditionalConditions(),
  922. }
  923. ) as EventQuery & LocationQuery;
  924. if (eventQuery.team && !eventQuery.team.length) {
  925. delete eventQuery.team;
  926. }
  927. if (!eventQuery.sort) {
  928. delete eventQuery.sort;
  929. }
  930. return eventQuery;
  931. }
  932. getResultsViewUrlTarget(slug: string): {pathname: string; query: Query} {
  933. return {
  934. pathname: `/organizations/${slug}/discover/results/`,
  935. query: this.generateQueryStringObject(),
  936. };
  937. }
  938. getResultsViewShortUrlTarget(slug: string): {pathname: string; query: Query} {
  939. const output = {id: this.id};
  940. for (const field of [...Object.values(URL_PARAM), 'cursor']) {
  941. if (this[field] && this[field].length) {
  942. output[field] = this[field];
  943. }
  944. }
  945. return {
  946. pathname: `/organizations/${slug}/discover/results/`,
  947. query: cloneDeep(output as any),
  948. };
  949. }
  950. getPerformanceTransactionEventsViewUrlTarget(
  951. slug: string,
  952. options: {
  953. showTransactions?: EventsDisplayFilterName;
  954. breakdown?: SpanOperationBreakdownFilter;
  955. webVital?: WebVital;
  956. }
  957. ): {pathname: string; query: Query} {
  958. const {showTransactions, breakdown, webVital} = options;
  959. const output = {
  960. sort: encodeSorts(this.sorts),
  961. project: this.project,
  962. query: this.query,
  963. transaction: this.name,
  964. showTransactions,
  965. breakdown,
  966. webVital,
  967. };
  968. for (const field of EXTERNAL_QUERY_STRING_KEYS) {
  969. if (this[field] && this[field].length) {
  970. output[field] = this[field];
  971. }
  972. }
  973. const query = cloneDeep(output as any);
  974. return {
  975. pathname: `/organizations/${slug}/performance/summary/events/`,
  976. query,
  977. };
  978. }
  979. sortForField(field: Field, tableMeta: MetaType | undefined): Sort | undefined {
  980. if (!tableMeta) {
  981. return undefined;
  982. }
  983. return this.sorts.find(sort => isSortEqualToField(sort, field, tableMeta));
  984. }
  985. sortOnField(field: Field, tableMeta: MetaType, kind?: 'desc' | 'asc'): EventView {
  986. // check if field can be sorted
  987. if (!isFieldSortable(field, tableMeta)) {
  988. return this;
  989. }
  990. const needleIndex = this.sorts.findIndex(sort =>
  991. isSortEqualToField(sort, field, tableMeta)
  992. );
  993. if (needleIndex >= 0) {
  994. const newEventView = this.clone();
  995. const currentSort = this.sorts[needleIndex];
  996. const sorts = [...newEventView.sorts];
  997. sorts[needleIndex] = kind
  998. ? setSortOrder(currentSort, kind)
  999. : reverseSort(currentSort);
  1000. newEventView.sorts = sorts;
  1001. return newEventView;
  1002. }
  1003. // field is currently not sorted; so, we sort on it
  1004. const newEventView = this.clone();
  1005. // invariant: this is not falsey, since sortKey exists
  1006. const sort = fieldToSort(field, tableMeta, kind)!;
  1007. newEventView.sorts = [sort];
  1008. return newEventView;
  1009. }
  1010. getYAxisOptions(): SelectValue<string>[] {
  1011. // Make option set and add the default options in.
  1012. return uniqBy(
  1013. this.getAggregateFields()
  1014. // Only include aggregates that make sense to be graphable (eg. not string or date)
  1015. .filter(
  1016. (field: Field) =>
  1017. isLegalYAxisType(aggregateOutputType(field.field)) ||
  1018. isAggregateEquation(field.field)
  1019. )
  1020. .map((field: Field) => ({
  1021. label: isEquation(field.field) ? getEquation(field.field) : field.field,
  1022. value: field.field,
  1023. }))
  1024. .concat(CHART_AXIS_OPTIONS),
  1025. 'value'
  1026. );
  1027. }
  1028. getYAxis(): string {
  1029. const yAxisOptions = this.getYAxisOptions();
  1030. const yAxis = this.yAxis;
  1031. const defaultOption = yAxisOptions[0].value;
  1032. if (!yAxis) {
  1033. return defaultOption;
  1034. }
  1035. // ensure current selected yAxis is one of the items in yAxisOptions
  1036. const result = yAxisOptions.findIndex(
  1037. (option: SelectValue<string>) => option.value === yAxis
  1038. );
  1039. if (result >= 0) {
  1040. return yAxis;
  1041. }
  1042. return defaultOption;
  1043. }
  1044. getDisplayOptions(): SelectValue<string>[] {
  1045. return DISPLAY_MODE_OPTIONS.map(item => {
  1046. if (item.value === DisplayModes.PREVIOUS) {
  1047. if (this.start || this.end) {
  1048. return {...item, disabled: true};
  1049. }
  1050. }
  1051. if (item.value === DisplayModes.TOP5 || item.value === DisplayModes.DAILYTOP5) {
  1052. if (this.getAggregateFields().length === 0) {
  1053. return {
  1054. ...item,
  1055. disabled: true,
  1056. tooltip: t('Add a function that groups events to use this view.'),
  1057. };
  1058. }
  1059. }
  1060. if (item.value === DisplayModes.DAILY || item.value === DisplayModes.DAILYTOP5) {
  1061. if (this.getDays() < 1) {
  1062. return {
  1063. ...item,
  1064. disabled: true,
  1065. tooltip: t('Change the date rage to at least 1 day to use this view.'),
  1066. };
  1067. }
  1068. }
  1069. return item;
  1070. });
  1071. }
  1072. getDisplayMode() {
  1073. const mode = this.display ?? DisplayModes.DEFAULT;
  1074. const displayOptions = this.getDisplayOptions();
  1075. let display = (Object.values(DisplayModes) as string[]).includes(mode)
  1076. ? mode
  1077. : DisplayModes.DEFAULT;
  1078. const cond = option => option.value === display;
  1079. // Just in case we define a fallback chain that results in an infinite loop.
  1080. // The number 5 isn't anything special, its just larger than the longest fallback
  1081. // chain that exists and isn't too big.
  1082. for (let i = 0; i < 5; i++) {
  1083. const selectedOption = displayOptions.find(cond);
  1084. if (selectedOption && !selectedOption.disabled) {
  1085. return display;
  1086. }
  1087. display = DISPLAY_MODE_FALLBACK_OPTIONS[display];
  1088. }
  1089. // after trying to find an enabled display mode and failing to find one,
  1090. // we just use the default display mode
  1091. return DisplayModes.DEFAULT;
  1092. }
  1093. getQueryWithAdditionalConditions() {
  1094. const {query} = this;
  1095. if (this.additionalConditions.isEmpty()) {
  1096. return query;
  1097. }
  1098. const conditions = tokenizeSearch(query);
  1099. Object.entries(this.additionalConditions.tagValues).forEach(([tag, tagValues]) => {
  1100. conditions.addTagValues(tag, tagValues);
  1101. });
  1102. return conditions.formatString();
  1103. }
  1104. }
  1105. const isFieldsSimilar = (
  1106. currentValue: Array<string>,
  1107. otherValue: Array<string>
  1108. ): boolean => {
  1109. // For equation's their order matters because we alias them based on index
  1110. const currentEquations = currentValue.filter(isEquation);
  1111. const otherEquations = otherValue.filter(isEquation);
  1112. // Field orders don't matter, so using a set for comparison
  1113. const currentFields = new Set(currentValue.filter(value => !isEquation(value)));
  1114. const otherFields = new Set(otherValue.filter(value => !isEquation(value)));
  1115. if (!isEqual(currentEquations, otherEquations)) {
  1116. return false;
  1117. } else if (!isEqual(currentFields, otherFields)) {
  1118. return false;
  1119. }
  1120. return true;
  1121. };
  1122. export const isAPIPayloadSimilar = (
  1123. current: EventQuery & LocationQuery,
  1124. other: EventQuery & LocationQuery
  1125. ): boolean => {
  1126. const currentKeys = new Set(Object.keys(current));
  1127. const otherKeys = new Set(Object.keys(other));
  1128. if (!isEqual(currentKeys, otherKeys)) {
  1129. return false;
  1130. }
  1131. for (const key of currentKeys) {
  1132. const currentValue = current[key];
  1133. const otherValue = other[key];
  1134. if (key === 'field') {
  1135. if (!isFieldsSimilar(currentValue, otherValue)) {
  1136. return false;
  1137. }
  1138. } else {
  1139. const currentTarget = Array.isArray(currentValue)
  1140. ? new Set(currentValue)
  1141. : currentValue;
  1142. const otherTarget = Array.isArray(otherValue) ? new Set(otherValue) : otherValue;
  1143. if (!isEqual(currentTarget, otherTarget)) {
  1144. return false;
  1145. }
  1146. }
  1147. }
  1148. return true;
  1149. };
  1150. export function pickRelevantLocationQueryStrings(location: Location) {
  1151. const query = location.query || {};
  1152. const picked = pick(query || {}, EXTERNAL_QUERY_STRING_KEYS);
  1153. return picked;
  1154. }
  1155. export default EventView;