eventsRequest.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  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. * Optional callback to further process raw events request response data
  125. */
  126. dataLoadedCallback?: (any: EventsStats | MultiSeriesEventsStats | null) => void;
  127. /**
  128. * List of environments to query
  129. */
  130. environment?: Readonly<string[]>;
  131. /**
  132. * Is query out of retention
  133. */
  134. expired?: boolean;
  135. /**
  136. * List of fields to group with when doing a topEvents request.
  137. */
  138. field?: string[];
  139. /**
  140. * Allows overriding the pathname.
  141. */
  142. generatePathname?: (org: OrganizationSummary) => string;
  143. /**
  144. * Hide error toast (used for pages which also query eventsV2). Stops error appearing as a toast.
  145. */
  146. hideError?: boolean;
  147. /**
  148. * Initial loading state
  149. */
  150. loading?: boolean;
  151. /**
  152. * Query name used for displaying error toast if it is out of retention
  153. */
  154. name?: string;
  155. /**
  156. * A way to control error if error handling is not owned by the toast.
  157. */
  158. onError?: (error: string) => void;
  159. /**
  160. * How to order results when getting top events.
  161. */
  162. orderby?: string;
  163. previousSeriesNames?: string[];
  164. /**
  165. * List of project ids to query
  166. */
  167. project?: Readonly<number[]>;
  168. /**
  169. * A container for query batching data and functions.
  170. */
  171. queryBatching?: QueryBatching;
  172. /**
  173. * Extra query parameters to be added.
  174. */
  175. queryExtras?: Record<string, string>;
  176. /**
  177. * A unique name for what's triggering this request, see organization_events_stats for an allowlist
  178. */
  179. referrer?: string;
  180. /**
  181. * Should loading be shown.
  182. */
  183. showLoading?: boolean;
  184. /**
  185. * List of team ids to query
  186. */
  187. team?: Readonly<string | string[]>;
  188. /**
  189. * The number of top results to get. When set a multi-series result will be returned
  190. * in the `results` child render function.
  191. */
  192. topEvents?: number;
  193. /**
  194. * Whether or not to zerofill results
  195. */
  196. withoutZerofill?: boolean;
  197. /**
  198. * The yAxis being plotted. If multiple yAxis are requested,
  199. * the child render function will be called with `results`
  200. */
  201. yAxis?: string | string[];
  202. };
  203. type TimeAggregationProps =
  204. | {includeTimeAggregation: true; timeAggregationSeriesName: string}
  205. | {includeTimeAggregation?: false; timeAggregationSeriesName?: undefined};
  206. export type EventsRequestProps = DefaultProps &
  207. TimeAggregationProps &
  208. EventsRequestPartialProps;
  209. type EventsRequestState = {
  210. errored: boolean;
  211. fetchedWithPrevious: boolean;
  212. reloading: boolean;
  213. timeseriesData: null | EventsStats | MultiSeriesEventsStats;
  214. errorMessage?: string;
  215. };
  216. const propNamesToIgnore = [
  217. 'api',
  218. 'children',
  219. 'organization',
  220. 'loading',
  221. 'queryBatching',
  222. 'generatePathname',
  223. ];
  224. const omitIgnoredProps = (props: EventsRequestProps) =>
  225. omitBy(props, (_value, key) => propNamesToIgnore.includes(key));
  226. class EventsRequest extends PureComponent<EventsRequestProps, EventsRequestState> {
  227. static defaultProps: DefaultProps = {
  228. period: undefined,
  229. start: null,
  230. end: null,
  231. interval: '1d',
  232. comparisonDelta: undefined,
  233. limit: 15,
  234. query: '',
  235. includePrevious: true,
  236. includeTransformedData: true,
  237. };
  238. state: EventsRequestState = {
  239. reloading: !!this.props.loading,
  240. errored: false,
  241. timeseriesData: null,
  242. fetchedWithPrevious: false,
  243. };
  244. componentDidMount() {
  245. this.fetchData();
  246. }
  247. componentDidUpdate(prevProps: EventsRequestProps) {
  248. if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
  249. return;
  250. }
  251. this.fetchData();
  252. }
  253. componentWillUnmount() {
  254. this.unmounting = true;
  255. }
  256. private unmounting: boolean = false;
  257. fetchData = async () => {
  258. const {api, confirmedQuery, onError, expired, name, hideError, ...props} = this.props;
  259. let timeseriesData: EventsStats | MultiSeriesEventsStats | null = null;
  260. if (confirmedQuery === false) {
  261. return;
  262. }
  263. this.setState(state => ({
  264. reloading: state.timeseriesData !== null,
  265. errored: false,
  266. errorMessage: undefined,
  267. }));
  268. let errorMessage;
  269. if (expired) {
  270. errorMessage = t(
  271. '%s has an invalid date range. Please try a more recent date range.',
  272. name
  273. );
  274. addErrorMessage(errorMessage, {append: true});
  275. this.setState({
  276. errored: true,
  277. errorMessage,
  278. });
  279. } else {
  280. try {
  281. api.clear();
  282. timeseriesData = await doEventsRequest(api, props);
  283. } catch (resp) {
  284. if (resp && resp.responseJSON && resp.responseJSON.detail) {
  285. errorMessage = resp.responseJSON.detail;
  286. } else {
  287. errorMessage = t('Error loading chart data');
  288. }
  289. if (!hideError) {
  290. addErrorMessage(errorMessage);
  291. }
  292. if (onError) {
  293. onError(errorMessage);
  294. }
  295. this.setState({
  296. errored: true,
  297. errorMessage,
  298. });
  299. }
  300. }
  301. if (this.unmounting) {
  302. return;
  303. }
  304. this.setState({
  305. reloading: false,
  306. timeseriesData,
  307. fetchedWithPrevious: props.includePrevious,
  308. });
  309. if (props.dataLoadedCallback) {
  310. props.dataLoadedCallback(timeseriesData);
  311. }
  312. };
  313. /**
  314. * Retrieves dataset for the current period (since data can potentially
  315. * contain previous period's data), as well as the previous period if
  316. * possible.
  317. *
  318. * Returns `null` if data does not exist
  319. */
  320. getData = (
  321. data: EventsStatsData
  322. ): {current: EventsStatsData; previous: EventsStatsData | null} => {
  323. const {fetchedWithPrevious} = this.state;
  324. const {period, includePrevious} = this.props;
  325. const hasPreviousPeriod =
  326. fetchedWithPrevious || canIncludePreviousPeriod(includePrevious, period);
  327. // Take the floor just in case, but data should always be divisible by 2
  328. const dataMiddleIndex = Math.floor(data.length / 2);
  329. return {
  330. current: hasPreviousPeriod ? data.slice(dataMiddleIndex) : data,
  331. previous: hasPreviousPeriod ? data.slice(0, dataMiddleIndex) : null,
  332. };
  333. };
  334. // This aggregates all values per `timestamp`
  335. calculateTotalsPerTimestamp(
  336. data: EventsStatsData,
  337. getName: (
  338. timestamp: number,
  339. countArray: {count: number}[],
  340. i: number
  341. ) => number = timestamp => timestamp * 1000
  342. ): SeriesDataUnit[] {
  343. return data.map(([timestamp, countArray], i) => ({
  344. name: getName(timestamp, countArray, i),
  345. value: countArray.reduce((acc, {count}) => acc + count, 0),
  346. }));
  347. }
  348. /**
  349. * Get previous period data, but transform timestamps so that data fits unto
  350. * the current period's data axis
  351. */
  352. transformPreviousPeriodData(
  353. current: EventsStatsData,
  354. previous: EventsStatsData | null,
  355. seriesName?: string
  356. ): Series | null {
  357. // Need the current period data array so we can take the timestamp
  358. // so we can be sure the data lines up
  359. if (!previous) {
  360. return null;
  361. }
  362. return {
  363. seriesName: seriesName ?? 'Previous',
  364. data: this.calculateTotalsPerTimestamp(
  365. previous,
  366. (_timestamp, _countArray, i) => current[i][0] * 1000
  367. ),
  368. stack: 'previous',
  369. };
  370. }
  371. /**
  372. * Aggregate all counts for each time stamp
  373. */
  374. transformAggregatedTimeseries(data: EventsStatsData, seriesName: string = ''): Series {
  375. return {
  376. seriesName,
  377. data: this.calculateTotalsPerTimestamp(data),
  378. };
  379. }
  380. /**
  381. * Transforms query response into timeseries data to be used in a chart
  382. */
  383. transformTimeseriesData(data: EventsStatsData, seriesName?: string): Series[] {
  384. return [
  385. {
  386. seriesName: seriesName || 'Current',
  387. data: data.map(([timestamp, countsForTimestamp]) => ({
  388. name: timestamp * 1000,
  389. value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0),
  390. })),
  391. },
  392. ];
  393. }
  394. /**
  395. * Transforms comparisonCount in query response into timeseries data to be used in a comparison chart for change alerts
  396. */
  397. transformComparisonTimeseriesData(data: EventsStatsData): Series[] {
  398. return [
  399. {
  400. seriesName: 'comparisonCount()',
  401. data: data.map(([timestamp, countsForTimestamp]) => ({
  402. name: timestamp * 1000,
  403. value: countsForTimestamp.reduce(
  404. (acc, {comparisonCount}) => acc + (comparisonCount ?? 0),
  405. 0
  406. ),
  407. })),
  408. },
  409. ];
  410. }
  411. processData(response: EventsStats, seriesIndex: number = 0, seriesName?: string) {
  412. const {data, isMetricsData, totals} = response;
  413. const {
  414. includeTransformedData,
  415. includeTimeAggregation,
  416. timeAggregationSeriesName,
  417. currentSeriesNames,
  418. previousSeriesNames,
  419. comparisonDelta,
  420. } = this.props;
  421. const {current, previous} = this.getData(data);
  422. const transformedData = includeTransformedData
  423. ? this.transformTimeseriesData(
  424. current,
  425. seriesName ?? currentSeriesNames?.[seriesIndex]
  426. )
  427. : [];
  428. const transformedComparisonData =
  429. includeTransformedData && comparisonDelta
  430. ? this.transformComparisonTimeseriesData(current)
  431. : [];
  432. const previousData = includeTransformedData
  433. ? this.transformPreviousPeriodData(
  434. current,
  435. previous,
  436. (seriesName ? getPreviousSeriesName(seriesName) : undefined) ??
  437. previousSeriesNames?.[seriesIndex]
  438. )
  439. : null;
  440. const timeAggregatedData = includeTimeAggregation
  441. ? this.transformAggregatedTimeseries(current, timeAggregationSeriesName || '')
  442. : {};
  443. const timeframe =
  444. response.start && response.end
  445. ? !previous
  446. ? {
  447. start: response.start * 1000,
  448. end: response.end * 1000,
  449. }
  450. : {
  451. // Find the midpoint of start & end since previous includes 2x data
  452. start: (response.start + response.end) * 500,
  453. end: response.end * 1000,
  454. }
  455. : undefined;
  456. const processedData = {
  457. data: transformedData,
  458. comparisonData: transformedComparisonData,
  459. allData: data,
  460. originalData: current,
  461. totals,
  462. isMetricsData,
  463. originalPreviousData: previous,
  464. previousData,
  465. timeAggregatedData,
  466. timeframe,
  467. };
  468. return processedData;
  469. }
  470. render() {
  471. const {children, showLoading, ...props} = this.props;
  472. const {topEvents} = this.props;
  473. const {timeseriesData, reloading, errored, errorMessage} = this.state;
  474. // Is "loading" if data is null
  475. const loading = this.props.loading || timeseriesData === null;
  476. if (showLoading && loading) {
  477. return <LoadingPanel data-test-id="events-request-loading" />;
  478. }
  479. if (isMultiSeriesStats(timeseriesData, defined(topEvents))) {
  480. // Convert multi-series results into chartable series. Multi series results
  481. // are created when multiple yAxis are used or a topEvents request is made.
  482. // Convert the timeseries data into a multi-series result set.
  483. // As the server will have replied with a map like:
  484. // {[titleString: string]: EventsStats}
  485. let timeframe: {end: number; start: number} | undefined = undefined;
  486. const seriesAdditionalInfo: Record<string, AdditionalSeriesInfo> = {};
  487. const sortedTimeseriesData = Object.keys(timeseriesData)
  488. .map(
  489. (
  490. seriesName: string,
  491. index: number
  492. ): [number, Series, Series | null, AdditionalSeriesInfo] => {
  493. const seriesData: EventsStats = timeseriesData[seriesName];
  494. const processedData = this.processData(
  495. seriesData,
  496. index,
  497. stripEquationPrefix(seriesName)
  498. );
  499. if (!timeframe) {
  500. timeframe = processedData.timeframe;
  501. }
  502. if (processedData.isMetricsData) {
  503. seriesAdditionalInfo[seriesName] = {
  504. isMetricsData: processedData.isMetricsData,
  505. };
  506. }
  507. return [
  508. seriesData.order || 0,
  509. processedData.data[0],
  510. processedData.previousData,
  511. {isMetricsData: processedData.isMetricsData},
  512. ];
  513. }
  514. )
  515. .sort((a, b) => a[0] - b[0]);
  516. const results: Series[] = sortedTimeseriesData.map(item => {
  517. return item[1];
  518. });
  519. const previousTimeseriesData: Series[] | undefined = sortedTimeseriesData.some(
  520. item => item[2] === null
  521. )
  522. ? undefined
  523. : sortedTimeseriesData.map(item => {
  524. return item[2] as Series;
  525. });
  526. return children({
  527. loading,
  528. reloading,
  529. errored,
  530. errorMessage,
  531. results,
  532. timeframe,
  533. previousTimeseriesData,
  534. seriesAdditionalInfo,
  535. // sometimes we want to reference props that were given to EventsRequest
  536. ...props,
  537. });
  538. }
  539. if (timeseriesData) {
  540. const {
  541. data: transformedTimeseriesData,
  542. comparisonData: transformedComparisonTimeseriesData,
  543. allData: allTimeseriesData,
  544. originalData: originalTimeseriesData,
  545. totals: timeseriesTotals,
  546. originalPreviousData: originalPreviousTimeseriesData,
  547. previousData: previousTimeseriesData,
  548. timeAggregatedData,
  549. timeframe,
  550. isMetricsData,
  551. } = this.processData(timeseriesData);
  552. const seriesAdditionalInfo = {
  553. [this.props.currentSeriesNames?.[0] ?? 'current']: {isMetricsData},
  554. };
  555. return children({
  556. loading,
  557. reloading,
  558. errored,
  559. errorMessage,
  560. // meta data,
  561. seriesAdditionalInfo,
  562. // timeseries data
  563. timeseriesData: transformedTimeseriesData,
  564. comparisonTimeseriesData: transformedComparisonTimeseriesData,
  565. allTimeseriesData,
  566. originalTimeseriesData,
  567. timeseriesTotals,
  568. originalPreviousTimeseriesData,
  569. previousTimeseriesData: previousTimeseriesData
  570. ? [previousTimeseriesData]
  571. : previousTimeseriesData,
  572. timeAggregatedData,
  573. timeframe,
  574. // sometimes we want to reference props that were given to EventsRequest
  575. ...props,
  576. });
  577. }
  578. return children({
  579. loading,
  580. reloading,
  581. errored,
  582. errorMessage,
  583. ...props,
  584. });
  585. }
  586. }
  587. export default EventsRequest;