eventsRequest.tsx 19 KB

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