releasesAdoptionChart.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import {Component} from 'react';
  2. import {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import type {LineSeriesOption} from 'echarts';
  5. import {Location} from 'history';
  6. import compact from 'lodash/compact';
  7. import pick from 'lodash/pick';
  8. import moment from 'moment';
  9. import {Client} from 'sentry/api';
  10. import ChartZoom from 'sentry/components/charts/chartZoom';
  11. import {LineChart} from 'sentry/components/charts/lineChart';
  12. import SessionsRequest from 'sentry/components/charts/sessionsRequest';
  13. import {
  14. HeaderTitleLegend,
  15. InlineContainer,
  16. SectionHeading,
  17. SectionValue,
  18. } from 'sentry/components/charts/styles';
  19. import TransitionChart from 'sentry/components/charts/transitionChart';
  20. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  21. import {
  22. getDiffInMinutes,
  23. ONE_WEEK,
  24. truncationFormatter,
  25. } from 'sentry/components/charts/utils';
  26. import Count from 'sentry/components/count';
  27. import {
  28. normalizeDateTimeParams,
  29. parseStatsPeriod,
  30. StatsPeriodType,
  31. } from 'sentry/components/organizations/pageFilters/parse';
  32. import {Panel, PanelBody, PanelFooter} from 'sentry/components/panels';
  33. import Placeholder from 'sentry/components/placeholder';
  34. import {URL_PARAM} from 'sentry/constants/pageFilters';
  35. import {t, tct, tn} from 'sentry/locale';
  36. import space from 'sentry/styles/space';
  37. import {Organization, PageFilters, SessionApiResponse} from 'sentry/types';
  38. import {EChartClickHandler} from 'sentry/types/echarts';
  39. import {formatVersion} from 'sentry/utils/formatters';
  40. import {decodeScalar} from 'sentry/utils/queryString';
  41. import {getAdoptionSeries, getCount} from 'sentry/utils/sessions';
  42. import withApi from 'sentry/utils/withApi';
  43. import {sessionDisplayToField} from 'sentry/views/releases/list/releasesRequest';
  44. import {ReleasesDisplayOption} from './releasesDisplayOptions';
  45. type Props = {
  46. activeDisplay: ReleasesDisplayOption;
  47. api: Client;
  48. location: Location;
  49. organization: Organization;
  50. router: InjectedRouter;
  51. selection: PageFilters;
  52. };
  53. class ReleasesAdoptionChart extends Component<Props> {
  54. // needs to have different granularity, that's why we use custom getInterval instead of getSessionsInterval
  55. getInterval() {
  56. const {organization, location} = this.props;
  57. const datetimeObj = {
  58. start: decodeScalar(location.query.start),
  59. end: decodeScalar(location.query.end),
  60. period: decodeScalar(location.query.statsPeriod),
  61. };
  62. const diffInMinutes = getDiffInMinutes(datetimeObj);
  63. // use high fidelity intervals when available
  64. // limit on backend is set to six hour
  65. if (
  66. organization.features.includes('minute-resolution-sessions') &&
  67. diffInMinutes < 360
  68. ) {
  69. return '10m';
  70. }
  71. if (diffInMinutes >= ONE_WEEK) {
  72. return '1d';
  73. }
  74. return '1h';
  75. }
  76. getReleasesSeries(response: SessionApiResponse | null) {
  77. const {activeDisplay} = this.props;
  78. // If there are many releases, display releases with the highest number of sessions
  79. // Often this due to many releases with low session counts or not filtering by environment
  80. let releases: string[] | undefined =
  81. response?.groups.map(group => group.by.release as string) ?? [];
  82. if (response?.groups && response.groups.length > 50) {
  83. releases = response!.groups
  84. .sort((a, b) => b.totals['sum(session)'] - a.totals['sum(session)'])
  85. .slice(0, 50)
  86. .map(group => group.by.release as string);
  87. }
  88. if (!releases) {
  89. return null;
  90. }
  91. return releases.map(release => ({
  92. id: release,
  93. seriesName: formatVersion(release),
  94. data: getAdoptionSeries(
  95. [response?.groups.find(({by}) => by.release === release)!],
  96. response?.groups,
  97. response?.intervals,
  98. sessionDisplayToField(activeDisplay)
  99. ),
  100. emphasis: {
  101. focus: 'series',
  102. } as LineSeriesOption['emphasis'],
  103. }));
  104. }
  105. handleClick: EChartClickHandler = params => {
  106. const {organization, router, selection, location} = this.props;
  107. const project = selection.projects[0];
  108. router.push({
  109. pathname: `/organizations/${organization?.slug}/releases/${encodeURIComponent(
  110. params.seriesId
  111. )}/`,
  112. query: {project, environment: location.query.environment},
  113. });
  114. };
  115. renderEmpty() {
  116. return (
  117. <Panel>
  118. <PanelBody withPadding>
  119. <ChartHeader>
  120. <Placeholder height="24px" />
  121. </ChartHeader>
  122. <Placeholder height="200px" />
  123. </PanelBody>
  124. <ChartFooter>
  125. <Placeholder height="34px" />
  126. </ChartFooter>
  127. </Panel>
  128. );
  129. }
  130. render() {
  131. const {activeDisplay, router, selection, api, organization, location} = this.props;
  132. const {start, end, period, utc} = selection.datetime;
  133. const interval = this.getInterval();
  134. const field = sessionDisplayToField(activeDisplay);
  135. return (
  136. <SessionsRequest
  137. api={api}
  138. organization={organization}
  139. interval={interval}
  140. groupBy={['release']}
  141. field={[field]}
  142. {...normalizeDateTimeParams(pick(location.query, Object.values(URL_PARAM)))}
  143. >
  144. {({response, loading, reloading}) => {
  145. const totalCount = getCount(response?.groups, field);
  146. const releasesSeries = this.getReleasesSeries(response);
  147. if (loading) {
  148. return this.renderEmpty();
  149. }
  150. if (!releasesSeries?.length) {
  151. return null;
  152. }
  153. const numDataPoints = releasesSeries[0].data.length;
  154. const xAxisData = releasesSeries[0].data.map(point => point.name);
  155. const hideLastPoint =
  156. releasesSeries.findIndex(
  157. series => series.data[numDataPoints - 1].value > 0
  158. ) === -1;
  159. return (
  160. <Panel>
  161. <PanelBody withPadding>
  162. <ChartHeader>
  163. <ChartTitle>{t('Release Adoption')}</ChartTitle>
  164. </ChartHeader>
  165. <TransitionChart loading={loading} reloading={reloading}>
  166. <TransparentLoadingMask visible={reloading} />
  167. <ChartZoom
  168. router={router}
  169. period={period}
  170. utc={utc}
  171. start={start}
  172. end={end}
  173. >
  174. {zoomRenderProps => (
  175. <LineChart
  176. {...zoomRenderProps}
  177. grid={{left: '10px', right: '10px', top: '40px', bottom: '0px'}}
  178. series={releasesSeries.map(series => ({
  179. ...series,
  180. data: hideLastPoint ? series.data.slice(0, -1) : series.data,
  181. }))}
  182. yAxis={{
  183. min: 0,
  184. max: 100,
  185. type: 'value',
  186. interval: 10,
  187. splitNumber: 10,
  188. data: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
  189. axisLabel: {
  190. formatter: '{value}%',
  191. },
  192. }}
  193. xAxis={{
  194. show: true,
  195. min: xAxisData[0],
  196. max: xAxisData[numDataPoints - 1],
  197. type: 'time',
  198. data: xAxisData,
  199. }}
  200. tooltip={{
  201. formatter: seriesParams => {
  202. const series = Array.isArray(seriesParams)
  203. ? seriesParams
  204. : [seriesParams];
  205. const timestamp = series[0].data[0];
  206. const [first, second, third, ...rest] = series
  207. .filter(s => s.data[1] > 0)
  208. .sort((a, b) => b.data[1] - a.data[1]);
  209. const restSum = rest.reduce((acc, s) => acc + s.data[1], 0);
  210. const seriesToRender = compact([first, second, third]);
  211. if (rest.length) {
  212. seriesToRender.push({
  213. seriesName: tn('%s Other', '%s Others', rest.length),
  214. data: [timestamp, restSum],
  215. marker:
  216. '<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;"></span>',
  217. });
  218. }
  219. if (!seriesToRender.length) {
  220. return '<div/>';
  221. }
  222. const periodObj = parseStatsPeriod(interval) || {
  223. periodLength: 'd',
  224. period: '1',
  225. };
  226. const intervalStart = moment(timestamp).format('MMM D LT');
  227. const intervalEnd = (
  228. series[0].dataIndex === numDataPoints - 1
  229. ? moment(response?.end)
  230. : moment(timestamp).add(
  231. parseInt(periodObj.period, 10),
  232. periodObj.periodLength as StatsPeriodType
  233. )
  234. ).format('MMM D LT');
  235. return [
  236. '<div class="tooltip-series">',
  237. seriesToRender
  238. .map(
  239. s =>
  240. `<div><span class="tooltip-label">${
  241. s.marker
  242. }<strong>${
  243. s.seriesName &&
  244. truncationFormatter(s.seriesName, 12)
  245. }</strong></span>${s.data[1].toFixed(2)}%</div>`
  246. )
  247. .join(''),
  248. '</div>',
  249. `<div class="tooltip-date">${intervalStart} &mdash; ${intervalEnd}</div>`,
  250. '<div class="tooltip-arrow"></div>',
  251. ].join('');
  252. },
  253. }}
  254. onClick={this.handleClick}
  255. />
  256. )}
  257. </ChartZoom>
  258. </TransitionChart>
  259. </PanelBody>
  260. <ChartFooter>
  261. <InlineContainer>
  262. <SectionHeading>
  263. {tct('Total [display]', {
  264. display:
  265. activeDisplay === ReleasesDisplayOption.USERS
  266. ? 'Users'
  267. : 'Sessions',
  268. })}
  269. </SectionHeading>
  270. <SectionValue>
  271. <Count value={totalCount || 0} />
  272. </SectionValue>
  273. </InlineContainer>
  274. </ChartFooter>
  275. </Panel>
  276. );
  277. }}
  278. </SessionsRequest>
  279. );
  280. }
  281. }
  282. export default withApi(ReleasesAdoptionChart);
  283. const ChartHeader = styled(HeaderTitleLegend)`
  284. margin-bottom: ${space(1)};
  285. `;
  286. const ChartTitle = styled('header')`
  287. display: flex;
  288. flex-direction: row;
  289. `;
  290. const ChartFooter = styled(PanelFooter)`
  291. display: flex;
  292. align-items: center;
  293. padding: ${space(1)} 20px;
  294. `;