releaseStatsRequest.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import * as React from 'react';
  2. import {withTheme} from '@emotion/react';
  3. import {Location} from 'history';
  4. import isEqual from 'lodash/isEqual';
  5. import meanBy from 'lodash/meanBy';
  6. import omitBy from 'lodash/omitBy';
  7. import pick from 'lodash/pick';
  8. import {fetchTotalCount} from 'app/actionCreators/events';
  9. import {addErrorMessage} from 'app/actionCreators/indicator';
  10. import {Client} from 'app/api';
  11. import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams';
  12. import {URL_PARAM} from 'app/constants/globalSelectionHeader';
  13. import {t, tct} from 'app/locale';
  14. import {GlobalSelection, Organization, SessionApiResponse} from 'app/types';
  15. import {Series} from 'app/types/echarts';
  16. import {defined} from 'app/utils';
  17. import {WebVital} from 'app/utils/discover/fields';
  18. import {getExactDuration} from 'app/utils/formatters';
  19. import {Theme} from 'app/utils/theme';
  20. import {MutableSearch} from 'app/utils/tokenizeSearch';
  21. import {displayCrashFreePercent, roundDuration} from '../../utils';
  22. import {EventType, YAxis} from './chart/releaseChartControls';
  23. import {
  24. fillChartDataFromSessionsResponse,
  25. fillCrashFreeChartDataFromSessionsReponse,
  26. getInterval,
  27. getReleaseEventView,
  28. getTotalsFromSessionsResponse,
  29. initCrashFreeChartData,
  30. initOtherCrashFreeChartData,
  31. initOtherSessionDurationChartData,
  32. initOtherSessionsBreakdownChartData,
  33. initSessionDurationChartData,
  34. initSessionsBreakdownChartData,
  35. } from './chart/utils';
  36. const omitIgnoredProps = (props: Props) =>
  37. omitBy(props, (_, key) =>
  38. ['api', 'orgId', 'projectSlug', 'location', 'children'].includes(key)
  39. );
  40. type Data = {
  41. chartData: Series[];
  42. chartSummary: React.ReactNode;
  43. };
  44. export type ReleaseStatsRequestRenderProps = Data & {
  45. loading: boolean;
  46. reloading: boolean;
  47. errored: boolean;
  48. };
  49. type Props = {
  50. api: Client;
  51. version: string;
  52. organization: Organization;
  53. projectSlug: string;
  54. selection: GlobalSelection;
  55. location: Location;
  56. yAxis: YAxis;
  57. eventType: EventType;
  58. vitalType: WebVital;
  59. children: (renderProps: ReleaseStatsRequestRenderProps) => React.ReactNode;
  60. hasHealthData: boolean;
  61. hasDiscover: boolean;
  62. hasPerformance: boolean;
  63. defaultStatsPeriod: string;
  64. theme: Theme;
  65. };
  66. type State = {
  67. reloading: boolean;
  68. errored: boolean;
  69. data: Data | null;
  70. };
  71. class ReleaseStatsRequest extends React.Component<Props, State> {
  72. state: State = {
  73. reloading: false,
  74. errored: false,
  75. data: null,
  76. };
  77. componentDidMount() {
  78. this.fetchData();
  79. }
  80. componentDidUpdate(prevProps: Props) {
  81. if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
  82. return;
  83. }
  84. this.fetchData();
  85. }
  86. componentWillUnmount() {
  87. this.unmounting = true;
  88. }
  89. private unmounting: boolean = false;
  90. get path() {
  91. const {organization} = this.props;
  92. return `/organizations/${organization.slug}/sessions/`;
  93. }
  94. get baseQueryParams() {
  95. const {version, organization, location, selection, defaultStatsPeriod} = this.props;
  96. return {
  97. query: new MutableSearch([`release:"${version}"`]).formatString(),
  98. interval: getInterval(selection.datetime, {
  99. highFidelity: organization.features.includes('minute-resolution-sessions'),
  100. }),
  101. ...getParams(pick(location.query, Object.values(URL_PARAM)), {
  102. defaultStatsPeriod,
  103. }),
  104. };
  105. }
  106. fetchData = async () => {
  107. let data: Data | null = null;
  108. const {yAxis, hasHealthData, hasDiscover, hasPerformance} = this.props;
  109. if (!hasHealthData && !hasDiscover && !hasPerformance) {
  110. return;
  111. }
  112. this.setState(state => ({
  113. reloading: state.data !== null,
  114. errored: false,
  115. }));
  116. try {
  117. if (yAxis === YAxis.SESSIONS) {
  118. data = await this.fetchSessions();
  119. }
  120. if (yAxis === YAxis.USERS) {
  121. data = await this.fetchUsers();
  122. }
  123. if (yAxis === YAxis.CRASH_FREE) {
  124. data = await this.fetchCrashFree();
  125. }
  126. if (yAxis === YAxis.SESSION_DURATION) {
  127. data = await this.fetchSessionDuration();
  128. }
  129. if (
  130. yAxis === YAxis.EVENTS ||
  131. yAxis === YAxis.FAILED_TRANSACTIONS ||
  132. yAxis === YAxis.COUNT_DURATION ||
  133. yAxis === YAxis.COUNT_VITAL
  134. ) {
  135. // this is used to get total counts for chart footer summary
  136. data = await this.fetchEventData();
  137. }
  138. } catch (error) {
  139. addErrorMessage(error.responseJSON?.detail ?? t('Error loading chart data'));
  140. this.setState({
  141. errored: true,
  142. data: null,
  143. });
  144. }
  145. if (!defined(data) && !this.state.errored) {
  146. // this should not happen
  147. this.setState({
  148. errored: true,
  149. data: null,
  150. });
  151. }
  152. if (this.unmounting) {
  153. return;
  154. }
  155. this.setState({
  156. reloading: false,
  157. data,
  158. });
  159. };
  160. async fetchSessions() {
  161. const {api, version, theme} = this.props;
  162. const [releaseResponse, otherReleasesResponse]: SessionApiResponse[] =
  163. await Promise.all([
  164. api.requestPromise(this.path, {
  165. query: {
  166. ...this.baseQueryParams,
  167. field: 'sum(session)',
  168. groupBy: 'session.status',
  169. },
  170. }),
  171. api.requestPromise(this.path, {
  172. query: {
  173. ...this.baseQueryParams,
  174. field: 'sum(session)',
  175. groupBy: 'session.status',
  176. query: new MutableSearch([`!release:"${version}"`]).formatString(),
  177. },
  178. }),
  179. ]);
  180. const totalSessions = getTotalsFromSessionsResponse({
  181. response: releaseResponse,
  182. field: 'sum(session)',
  183. });
  184. const chartData = fillChartDataFromSessionsResponse({
  185. response: releaseResponse,
  186. field: 'sum(session)',
  187. groupBy: 'session.status',
  188. chartData: initSessionsBreakdownChartData(theme),
  189. });
  190. const otherChartData = fillChartDataFromSessionsResponse({
  191. response: otherReleasesResponse,
  192. field: 'sum(session)',
  193. groupBy: 'session.status',
  194. chartData: initOtherSessionsBreakdownChartData(theme),
  195. });
  196. return {
  197. chartData: [...Object.values(chartData), ...Object.values(otherChartData)],
  198. chartSummary: totalSessions.toLocaleString(),
  199. };
  200. }
  201. async fetchUsers() {
  202. const {api, version, theme} = this.props;
  203. const [releaseResponse, otherReleasesResponse]: SessionApiResponse[] =
  204. await Promise.all([
  205. api.requestPromise(this.path, {
  206. query: {
  207. ...this.baseQueryParams,
  208. field: 'count_unique(user)',
  209. groupBy: 'session.status',
  210. },
  211. }),
  212. api.requestPromise(this.path, {
  213. query: {
  214. ...this.baseQueryParams,
  215. field: 'count_unique(user)',
  216. groupBy: 'session.status',
  217. query: new MutableSearch([`!release:"${version}"`]).formatString(),
  218. },
  219. }),
  220. ]);
  221. const totalUsers = getTotalsFromSessionsResponse({
  222. response: releaseResponse,
  223. field: 'count_unique(user)',
  224. });
  225. const chartData = fillChartDataFromSessionsResponse({
  226. response: releaseResponse,
  227. field: 'count_unique(user)',
  228. groupBy: 'session.status',
  229. chartData: initSessionsBreakdownChartData(theme),
  230. });
  231. const otherChartData = fillChartDataFromSessionsResponse({
  232. response: otherReleasesResponse,
  233. field: 'count_unique(user)',
  234. groupBy: 'session.status',
  235. chartData: initOtherSessionsBreakdownChartData(theme),
  236. });
  237. return {
  238. chartData: [...Object.values(chartData), ...Object.values(otherChartData)],
  239. chartSummary: totalUsers.toLocaleString(),
  240. };
  241. }
  242. async fetchCrashFree() {
  243. const {api, version} = this.props;
  244. const [releaseResponse, otherReleasesResponse]: SessionApiResponse[] =
  245. await Promise.all([
  246. api.requestPromise(this.path, {
  247. query: {
  248. ...this.baseQueryParams,
  249. field: ['sum(session)', 'count_unique(user)'],
  250. groupBy: 'session.status',
  251. },
  252. }),
  253. api.requestPromise(this.path, {
  254. query: {
  255. ...this.baseQueryParams,
  256. field: ['sum(session)', 'count_unique(user)'],
  257. groupBy: 'session.status',
  258. query: new MutableSearch([`!release:"${version}"`]).formatString(),
  259. },
  260. }),
  261. ]);
  262. let chartData = fillCrashFreeChartDataFromSessionsReponse({
  263. response: releaseResponse,
  264. field: 'sum(session)',
  265. entity: 'sessions',
  266. chartData: initCrashFreeChartData(),
  267. });
  268. chartData = fillCrashFreeChartDataFromSessionsReponse({
  269. response: releaseResponse,
  270. field: 'count_unique(user)',
  271. entity: 'users',
  272. chartData,
  273. });
  274. let otherChartData = fillCrashFreeChartDataFromSessionsReponse({
  275. response: otherReleasesResponse,
  276. field: 'sum(session)',
  277. entity: 'sessions',
  278. chartData: initOtherCrashFreeChartData(),
  279. });
  280. otherChartData = fillCrashFreeChartDataFromSessionsReponse({
  281. response: otherReleasesResponse,
  282. field: 'count_unique(user)',
  283. entity: 'users',
  284. chartData: otherChartData,
  285. });
  286. // summary is averaging previously rounded values - this might lead to a slightly skewed percentage
  287. const summary = tct('[usersPercent] users, [sessionsPercent] sessions', {
  288. usersPercent: displayCrashFreePercent(
  289. meanBy(
  290. chartData.users.data.filter(item => defined(item.value)),
  291. 'value'
  292. )
  293. ),
  294. sessionsPercent: displayCrashFreePercent(
  295. meanBy(
  296. chartData.sessions.data.filter(item => defined(item.value)),
  297. 'value'
  298. )
  299. ),
  300. });
  301. return {
  302. chartData: [...Object.values(chartData), ...Object.values(otherChartData)],
  303. chartSummary: summary,
  304. };
  305. }
  306. async fetchSessionDuration() {
  307. const {api, version} = this.props;
  308. const [releaseResponse, otherReleasesResponse]: SessionApiResponse[] =
  309. await Promise.all([
  310. api.requestPromise(this.path, {
  311. query: {
  312. ...this.baseQueryParams,
  313. field: 'p50(session.duration)',
  314. },
  315. }),
  316. api.requestPromise(this.path, {
  317. query: {
  318. ...this.baseQueryParams,
  319. field: 'p50(session.duration)',
  320. query: new MutableSearch([`!release:"${version}"`]).formatString(),
  321. },
  322. }),
  323. ]);
  324. const totalMedianDuration = getTotalsFromSessionsResponse({
  325. response: releaseResponse,
  326. field: 'p50(session.duration)',
  327. });
  328. const chartData = fillChartDataFromSessionsResponse({
  329. response: releaseResponse,
  330. field: 'p50(session.duration)',
  331. groupBy: null,
  332. chartData: initSessionDurationChartData(),
  333. valueFormatter: duration => roundDuration(duration ? duration / 1000 : 0),
  334. });
  335. const otherChartData = fillChartDataFromSessionsResponse({
  336. response: otherReleasesResponse,
  337. field: 'p50(session.duration)',
  338. groupBy: null,
  339. chartData: initOtherSessionDurationChartData(),
  340. valueFormatter: duration => roundDuration(duration ? duration / 1000 : 0),
  341. });
  342. return {
  343. chartData: [...Object.values(chartData), ...Object.values(otherChartData)],
  344. chartSummary: getExactDuration(
  345. roundDuration(totalMedianDuration ? totalMedianDuration / 1000 : 0)
  346. ),
  347. };
  348. }
  349. async fetchEventData() {
  350. const {api, organization, location, yAxis, eventType, vitalType, selection, version} =
  351. this.props;
  352. const eventView = getReleaseEventView(
  353. selection,
  354. version,
  355. yAxis,
  356. eventType,
  357. vitalType,
  358. organization,
  359. true
  360. );
  361. const payload = eventView.getEventsAPIPayload(location);
  362. const eventsCountResponse = await fetchTotalCount(api, organization.slug, payload);
  363. const chartSummary = eventsCountResponse.toLocaleString();
  364. return {chartData: [], chartSummary};
  365. }
  366. render() {
  367. const {children} = this.props;
  368. const {data, reloading, errored} = this.state;
  369. const loading = data === null;
  370. return children({
  371. loading,
  372. reloading,
  373. errored,
  374. chartData: data?.chartData ?? [],
  375. chartSummary: data?.chartSummary ?? '',
  376. });
  377. }
  378. }
  379. export default withTheme(ReleaseStatsRequest);