releasesAdoptionChart.tsx 12 KB

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