eventsRequest.tsx 19 KB

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