eventsRequest.tsx 19 KB

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