eventsRequest.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import {PureComponent} from 'react';
  2. import isEqual from 'lodash/isEqual';
  3. import omitBy from 'lodash/omitBy';
  4. import {doEventsRequest} from 'sentry/actionCreators/events';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import {Client} from 'sentry/api';
  7. import LoadingPanel from 'sentry/components/charts/loadingPanel';
  8. import {
  9. canIncludePreviousPeriod,
  10. getPreviousSeriesName,
  11. isMultiSeriesStats,
  12. } from 'sentry/components/charts/utils';
  13. import {t} from 'sentry/locale';
  14. import {
  15. DateString,
  16. EventsStats,
  17. EventsStatsData,
  18. MultiSeriesEventsStats,
  19. OrganizationSummary,
  20. } from 'sentry/types';
  21. import {Series, SeriesDataUnit} from 'sentry/types/echarts';
  22. import {defined} from 'sentry/utils';
  23. import {stripEquationPrefix} from 'sentry/utils/discover/fields';
  24. import {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';
  25. export type TimeSeriesData = {
  26. allTimeseriesData?: EventsStatsData;
  27. comparisonTimeseriesData?: Series[];
  28. originalPreviousTimeseriesData?: EventsStatsData | null;
  29. originalTimeseriesData?: EventsStatsData;
  30. previousTimeseriesData?: Series[] | null;
  31. timeAggregatedData?: Series | {};
  32. timeframe?: {end: number; start: number};
  33. // timeseries data
  34. timeseriesData?: Series[];
  35. timeseriesTotals?: {count: number};
  36. };
  37. type LoadingStatus = {
  38. /**
  39. * Whether there was an error retrieving data
  40. */
  41. errored: boolean;
  42. loading: boolean;
  43. reloading: boolean;
  44. errorMessage?: string;
  45. };
  46. // Can hold additional data from the root an events stat object (eg. start, end, order, isMetricsData).
  47. interface AdditionalSeriesInfo {
  48. isMetricsData?: boolean;
  49. }
  50. export type RenderProps = LoadingStatus &
  51. TimeSeriesData & {
  52. results?: Series[]; // Chart with multiple series.
  53. seriesAdditionalInfo?: Record<string, AdditionalSeriesInfo>;
  54. };
  55. type DefaultProps = {
  56. /**
  57. * Include data for previous period
  58. */
  59. includePrevious: boolean;
  60. /**
  61. * Transform the response data to be something ingestible by charts
  62. */
  63. includeTransformedData: boolean;
  64. /**
  65. * Interval to group results in
  66. *
  67. * e.g. 1d, 1h, 1m, 1s
  68. */
  69. interval: string;
  70. /**
  71. * number of rows to return
  72. */
  73. limit: number;
  74. /**
  75. * The query string to search events by
  76. */
  77. query: string;
  78. /**
  79. * Time delta for comparing intervals of alert metrics, in seconds
  80. */
  81. comparisonDelta?: number;
  82. /**
  83. * Absolute end date for query
  84. */
  85. end?: DateString;
  86. /**
  87. * Relative time period for query.
  88. *
  89. * Use `start` and `end` for absolute dates.
  90. *
  91. * e.g. 24h, 7d, 30d
  92. */
  93. period?: string | null;
  94. /**
  95. * Absolute start date for query
  96. */
  97. start?: DateString;
  98. };
  99. type EventsRequestPartialProps = {
  100. /**
  101. * API client instance
  102. */
  103. api: Client;
  104. children: (renderProps: RenderProps) => React.ReactNode;
  105. organization: OrganizationSummary;
  106. /**
  107. * Whether or not to include the last partial bucket. This happens for example when the
  108. * current time is 11:26 and the last bucket ranges from 11:25-11:30. This means that
  109. * the last bucket contains 1 minute worth of data while the rest contains 5 minutes.
  110. *
  111. * This flag indicates whether or not this last bucket should be included in the result.
  112. */
  113. partial: boolean;
  114. /**
  115. * Discover needs confirmation to run >30 day >10 project queries,
  116. * optional and when not passed confirmation is not required.
  117. */
  118. confirmedQuery?: boolean;
  119. /**
  120. * Name used for display current series dataset tooltip
  121. */
  122. currentSeriesNames?: string[];
  123. /**
  124. * List of environments to query
  125. */
  126. environment?: Readonly<string[]>;
  127. /**
  128. * Is query out of retention
  129. */
  130. expired?: boolean;
  131. /**
  132. * List of fields to group with when doing a topEvents request.
  133. */
  134. field?: string[];
  135. /**
  136. * Allows overriding the pathname.
  137. */
  138. generatePathname?: (org: OrganizationSummary) => string;
  139. /**
  140. * Hide error toast (used for pages which also query eventsV2). Stops error appearing as a toast.
  141. */
  142. hideError?: boolean;
  143. /**
  144. * Initial loading state
  145. */
  146. loading?: boolean;
  147. /**
  148. * Query name used for displaying error toast if it is out of retention
  149. */
  150. name?: string;
  151. /**
  152. * A way to control error if error handling is not owned by the toast.
  153. */
  154. onError?: (error: string) => void;
  155. /**
  156. * How to order results when getting top events.
  157. */
  158. orderby?: string;
  159. previousSeriesNames?: string[];
  160. /**
  161. * List of project ids to query
  162. */
  163. project?: Readonly<number[]>;
  164. /**
  165. * A container for query batching data and functions.
  166. */
  167. queryBatching?: QueryBatching;
  168. /**
  169. * Extra query parameters to be added.
  170. */
  171. queryExtras?: Record<string, string>;
  172. /**
  173. * A unique name for what's triggering this request, see organization_events_stats for an allowlist
  174. */
  175. referrer?: string;
  176. /**
  177. * Should loading be shown.
  178. */
  179. showLoading?: boolean;
  180. /**
  181. * List of team ids to query
  182. */
  183. team?: Readonly<string | string[]>;
  184. /**
  185. * The number of top results to get. When set a multi-series result will be returned
  186. * in the `results` child render function.
  187. */
  188. topEvents?: number;
  189. /**
  190. * Whether or not to zerofill results
  191. */
  192. withoutZerofill?: boolean;
  193. /**
  194. * The yAxis being plotted. If multiple yAxis are requested,
  195. * the child render function will be called with `results`
  196. */
  197. yAxis?: string | string[];
  198. };
  199. type TimeAggregationProps =
  200. | {includeTimeAggregation: true; timeAggregationSeriesName: string}
  201. | {includeTimeAggregation?: false; timeAggregationSeriesName?: undefined};
  202. export type EventsRequestProps = DefaultProps &
  203. TimeAggregationProps &
  204. EventsRequestPartialProps;
  205. type EventsRequestState = {
  206. errored: boolean;
  207. fetchedWithPrevious: boolean;
  208. reloading: boolean;
  209. timeseriesData: null | EventsStats | MultiSeriesEventsStats;
  210. errorMessage?: string;
  211. };
  212. const propNamesToIgnore = [
  213. 'api',
  214. 'children',
  215. 'organization',
  216. 'loading',
  217. 'queryBatching',
  218. 'generatePathname',
  219. ];
  220. const omitIgnoredProps = (props: EventsRequestProps) =>
  221. omitBy(props, (_value, key) => propNamesToIgnore.includes(key));
  222. class EventsRequest extends PureComponent<EventsRequestProps, EventsRequestState> {
  223. static defaultProps: DefaultProps = {
  224. period: undefined,
  225. start: null,
  226. end: null,
  227. interval: '1d',
  228. comparisonDelta: undefined,
  229. limit: 15,
  230. query: '',
  231. includePrevious: true,
  232. includeTransformedData: true,
  233. };
  234. state: EventsRequestState = {
  235. reloading: !!this.props.loading,
  236. errored: false,
  237. timeseriesData: null,
  238. fetchedWithPrevious: false,
  239. };
  240. componentDidMount() {
  241. this.fetchData();
  242. }
  243. componentDidUpdate(prevProps: EventsRequestProps) {
  244. if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
  245. return;
  246. }
  247. this.fetchData();
  248. }
  249. componentWillUnmount() {
  250. this.unmounting = true;
  251. }
  252. private unmounting: boolean = false;
  253. fetchData = async () => {
  254. const {api, confirmedQuery, onError, expired, name, hideError, ...props} = this.props;
  255. let timeseriesData: EventsStats | MultiSeriesEventsStats | null = null;
  256. if (confirmedQuery === false) {
  257. return;
  258. }
  259. this.setState(state => ({
  260. reloading: state.timeseriesData !== null,
  261. errored: false,
  262. errorMessage: undefined,
  263. }));
  264. let errorMessage;
  265. if (expired) {
  266. errorMessage = t(
  267. '%s has an invalid date range. Please try a more recent date range.',
  268. name
  269. );
  270. addErrorMessage(errorMessage, {append: true});
  271. this.setState({
  272. errored: true,
  273. errorMessage,
  274. });
  275. } else {
  276. try {
  277. api.clear();
  278. timeseriesData = await doEventsRequest(api, props);
  279. } catch (resp) {
  280. if (resp && resp.responseJSON && resp.responseJSON.detail) {
  281. errorMessage = resp.responseJSON.detail;
  282. } else {
  283. errorMessage = t('Error loading chart data');
  284. }
  285. if (!hideError) {
  286. addErrorMessage(errorMessage);
  287. }
  288. if (onError) {
  289. onError(errorMessage);
  290. }
  291. this.setState({
  292. errored: true,
  293. errorMessage,
  294. });
  295. }
  296. }
  297. if (this.unmounting) {
  298. return;
  299. }
  300. this.setState({
  301. reloading: false,
  302. timeseriesData,
  303. fetchedWithPrevious: props.includePrevious,
  304. });
  305. };
  306. /**
  307. * Retrieves dataset for the current period (since data can potentially
  308. * contain previous period's data), as well as the previous period if
  309. * possible.
  310. *
  311. * Returns `null` if data does not exist
  312. */
  313. getData = (
  314. data: EventsStatsData
  315. ): {current: EventsStatsData; previous: EventsStatsData | null} => {
  316. const {fetchedWithPrevious} = this.state;
  317. const {period, includePrevious} = this.props;
  318. const hasPreviousPeriod =
  319. fetchedWithPrevious || canIncludePreviousPeriod(includePrevious, period);
  320. // Take the floor just in case, but data should always be divisible by 2
  321. const dataMiddleIndex = Math.floor(data.length / 2);
  322. return {
  323. current: hasPreviousPeriod ? data.slice(dataMiddleIndex) : data,
  324. previous: hasPreviousPeriod ? data.slice(0, dataMiddleIndex) : null,
  325. };
  326. };
  327. // This aggregates all values per `timestamp`
  328. calculateTotalsPerTimestamp(
  329. data: EventsStatsData,
  330. getName: (
  331. timestamp: number,
  332. countArray: {count: number}[],
  333. i: number
  334. ) => number = timestamp => timestamp * 1000
  335. ): SeriesDataUnit[] {
  336. return data.map(([timestamp, countArray], i) => ({
  337. name: getName(timestamp, countArray, i),
  338. value: countArray.reduce((acc, {count}) => acc + count, 0),
  339. }));
  340. }
  341. /**
  342. * Get previous period data, but transform timestamps so that data fits unto
  343. * the current period's data axis
  344. */
  345. transformPreviousPeriodData(
  346. current: EventsStatsData,
  347. previous: EventsStatsData | null,
  348. seriesName?: string
  349. ): Series | null {
  350. // Need the current period data array so we can take the timestamp
  351. // so we can be sure the data lines up
  352. if (!previous) {
  353. return null;
  354. }
  355. return {
  356. seriesName: seriesName ?? 'Previous',
  357. data: this.calculateTotalsPerTimestamp(
  358. previous,
  359. (_timestamp, _countArray, i) => current[i][0] * 1000
  360. ),
  361. stack: 'previous',
  362. };
  363. }
  364. /**
  365. * Aggregate all counts for each time stamp
  366. */
  367. transformAggregatedTimeseries(data: EventsStatsData, seriesName: string = ''): Series {
  368. return {
  369. seriesName,
  370. data: this.calculateTotalsPerTimestamp(data),
  371. };
  372. }
  373. /**
  374. * Transforms query response into timeseries data to be used in a chart
  375. */
  376. transformTimeseriesData(data: EventsStatsData, seriesName?: string): Series[] {
  377. return [
  378. {
  379. seriesName: seriesName || 'Current',
  380. data: data.map(([timestamp, countsForTimestamp]) => ({
  381. name: timestamp * 1000,
  382. value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0),
  383. })),
  384. },
  385. ];
  386. }
  387. /**
  388. * Transforms comparisonCount in query response into timeseries data to be used in a comparison chart for change alerts
  389. */
  390. transformComparisonTimeseriesData(data: EventsStatsData): Series[] {
  391. return [
  392. {
  393. seriesName: 'comparisonCount()',
  394. data: data.map(([timestamp, countsForTimestamp]) => ({
  395. name: timestamp * 1000,
  396. value: countsForTimestamp.reduce(
  397. (acc, {comparisonCount}) => acc + (comparisonCount ?? 0),
  398. 0
  399. ),
  400. })),
  401. },
  402. ];
  403. }
  404. processData(response: EventsStats, seriesIndex: number = 0, seriesName?: string) {
  405. const {data, isMetricsData, totals} = response;
  406. const {
  407. includeTransformedData,
  408. includeTimeAggregation,
  409. timeAggregationSeriesName,
  410. currentSeriesNames,
  411. previousSeriesNames,
  412. comparisonDelta,
  413. } = this.props;
  414. const {current, previous} = this.getData(data);
  415. const transformedData = includeTransformedData
  416. ? this.transformTimeseriesData(
  417. current,
  418. seriesName ?? currentSeriesNames?.[seriesIndex]
  419. )
  420. : [];
  421. const transformedComparisonData =
  422. includeTransformedData && comparisonDelta
  423. ? this.transformComparisonTimeseriesData(current)
  424. : [];
  425. const previousData = includeTransformedData
  426. ? this.transformPreviousPeriodData(
  427. current,
  428. previous,
  429. (seriesName ? getPreviousSeriesName(seriesName) : undefined) ??
  430. previousSeriesNames?.[seriesIndex]
  431. )
  432. : null;
  433. const timeAggregatedData = includeTimeAggregation
  434. ? this.transformAggregatedTimeseries(current, timeAggregationSeriesName || '')
  435. : {};
  436. const timeframe =
  437. response.start && response.end
  438. ? !previous
  439. ? {
  440. start: response.start * 1000,
  441. end: response.end * 1000,
  442. }
  443. : {
  444. // Find the midpoint of start & end since previous includes 2x data
  445. start: (response.start + response.end) * 500,
  446. end: response.end * 1000,
  447. }
  448. : undefined;
  449. return {
  450. data: transformedData,
  451. comparisonData: transformedComparisonData,
  452. allData: data,
  453. originalData: current,
  454. totals,
  455. isMetricsData,
  456. originalPreviousData: previous,
  457. previousData,
  458. timeAggregatedData,
  459. timeframe,
  460. };
  461. }
  462. render() {
  463. const {children, showLoading, ...props} = this.props;
  464. const {topEvents} = this.props;
  465. const {timeseriesData, reloading, errored, errorMessage} = this.state;
  466. // Is "loading" if data is null
  467. const loading = this.props.loading || timeseriesData === null;
  468. if (showLoading && loading) {
  469. return <LoadingPanel data-test-id="events-request-loading" />;
  470. }
  471. if (isMultiSeriesStats(timeseriesData, defined(topEvents))) {
  472. // Convert multi-series results into chartable series. Multi series results
  473. // are created when multiple yAxis are used or a topEvents request is made.
  474. // Convert the timeseries data into a multi-series result set.
  475. // As the server will have replied with a map like:
  476. // {[titleString: string]: EventsStats}
  477. let timeframe: {end: number; start: number} | undefined = undefined;
  478. const seriesAdditionalInfo: Record<string, AdditionalSeriesInfo> = {};
  479. const sortedTimeseriesData = Object.keys(timeseriesData)
  480. .map(
  481. (
  482. seriesName: string,
  483. index: number
  484. ): [number, Series, Series | null, AdditionalSeriesInfo] => {
  485. const seriesData: EventsStats = timeseriesData[seriesName];
  486. const processedData = this.processData(
  487. seriesData,
  488. index,
  489. stripEquationPrefix(seriesName)
  490. );
  491. if (!timeframe) {
  492. timeframe = processedData.timeframe;
  493. }
  494. if (processedData.isMetricsData) {
  495. seriesAdditionalInfo[seriesName] = {
  496. isMetricsData: processedData.isMetricsData,
  497. };
  498. }
  499. return [
  500. seriesData.order || 0,
  501. processedData.data[0],
  502. processedData.previousData,
  503. {isMetricsData: processedData.isMetricsData},
  504. ];
  505. }
  506. )
  507. .sort((a, b) => a[0] - b[0]);
  508. const results: Series[] = sortedTimeseriesData.map(item => {
  509. return item[1];
  510. });
  511. const previousTimeseriesData: Series[] | undefined = sortedTimeseriesData.some(
  512. item => item[2] === null
  513. )
  514. ? undefined
  515. : sortedTimeseriesData.map(item => {
  516. return item[2] as Series;
  517. });
  518. return children({
  519. loading,
  520. reloading,
  521. errored,
  522. errorMessage,
  523. results,
  524. timeframe,
  525. previousTimeseriesData,
  526. seriesAdditionalInfo,
  527. // sometimes we want to reference props that were given to EventsRequest
  528. ...props,
  529. });
  530. }
  531. if (timeseriesData) {
  532. const {
  533. data: transformedTimeseriesData,
  534. comparisonData: transformedComparisonTimeseriesData,
  535. allData: allTimeseriesData,
  536. originalData: originalTimeseriesData,
  537. totals: timeseriesTotals,
  538. originalPreviousData: originalPreviousTimeseriesData,
  539. previousData: previousTimeseriesData,
  540. timeAggregatedData,
  541. timeframe,
  542. isMetricsData,
  543. } = this.processData(timeseriesData);
  544. const seriesAdditionalInfo = {
  545. [this.props.currentSeriesNames?.[0] ?? 'current']: {isMetricsData},
  546. };
  547. return children({
  548. loading,
  549. reloading,
  550. errored,
  551. errorMessage,
  552. // meta data,
  553. seriesAdditionalInfo,
  554. // timeseries data
  555. timeseriesData: transformedTimeseriesData,
  556. comparisonTimeseriesData: transformedComparisonTimeseriesData,
  557. allTimeseriesData,
  558. originalTimeseriesData,
  559. timeseriesTotals,
  560. originalPreviousTimeseriesData,
  561. previousTimeseriesData: previousTimeseriesData
  562. ? [previousTimeseriesData]
  563. : previousTimeseriesData,
  564. timeAggregatedData,
  565. timeframe,
  566. // sometimes we want to reference props that were given to EventsRequest
  567. ...props,
  568. });
  569. }
  570. return children({
  571. loading,
  572. reloading,
  573. errored,
  574. errorMessage,
  575. ...props,
  576. });
  577. }
  578. }
  579. export default EventsRequest;