eventsRequest.tsx 19 KB

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