releasesAdoptionChart.tsx 12 KB

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