releasesAdoptionChart.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import {Component} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import type {LineSeriesOption} from 'echarts';
  5. import type {Location} from 'history';
  6. import compact from 'lodash/compact';
  7. import pick from 'lodash/pick';
  8. import moment from 'moment-timezone';
  9. import type {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 type {StatsPeriodType} from 'sentry/components/organizations/pageFilters/parse';
  28. import {
  29. normalizeDateTimeParams,
  30. parseStatsPeriod,
  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 type {PageFilters} from 'sentry/types/core';
  40. import type {EChartClickHandler} from 'sentry/types/echarts';
  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, router, 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
  174. router={router}
  175. period={period}
  176. utc={utc}
  177. start={start}
  178. end={end}
  179. >
  180. {zoomRenderProps => (
  181. <LineChart
  182. {...zoomRenderProps}
  183. grid={{left: '10px', right: '10px', top: '40px', bottom: '0px'}}
  184. series={releasesSeries.map(series => ({
  185. ...series,
  186. data: hideLastPoint ? series.data.slice(0, -1) : series.data,
  187. }))}
  188. yAxis={{
  189. min: 0,
  190. max: 100,
  191. type: 'value',
  192. interval: 10,
  193. splitNumber: 10,
  194. axisLabel: {
  195. formatter: '{value}%',
  196. },
  197. }}
  198. xAxis={{
  199. show: true,
  200. min: xAxisData[0],
  201. max: xAxisData[numDataPoints - 1],
  202. type: 'time',
  203. }}
  204. tooltip={{
  205. formatter: seriesParams => {
  206. const series = Array.isArray(seriesParams)
  207. ? seriesParams
  208. : [seriesParams];
  209. const timestamp = series[0].data[0];
  210. const [first, second, third, ...rest] = series
  211. .filter(s => s.data[1] > 0)
  212. .sort((a, b) => b.data[1] - a.data[1]);
  213. const restSum = rest.reduce((acc, s) => acc + s.data[1], 0);
  214. const seriesToRender = compact([first, second, third]);
  215. if (rest.length) {
  216. seriesToRender.push({
  217. seriesName: tn('%s Other', '%s Others', rest.length),
  218. data: [timestamp, restSum],
  219. marker:
  220. '<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;"></span>',
  221. });
  222. }
  223. if (!seriesToRender.length) {
  224. return '<div/>';
  225. }
  226. const periodObj = parseStatsPeriod(interval) || {
  227. periodLength: 'd',
  228. period: '1',
  229. };
  230. const intervalStart = moment(timestamp).format('MMM D LT');
  231. const intervalEnd = (
  232. series[0].dataIndex === numDataPoints - 1
  233. ? moment(response?.end)
  234. : moment(timestamp).add(
  235. parseInt(periodObj.period, 10),
  236. periodObj.periodLength as StatsPeriodType
  237. )
  238. ).format('MMM D LT');
  239. return [
  240. '<div class="tooltip-series">',
  241. seriesToRender
  242. .map(
  243. s =>
  244. `<div><span class="tooltip-label">${
  245. s.marker
  246. }<strong>${
  247. s.seriesName &&
  248. truncationFormatter(s.seriesName, 32)
  249. }</strong></span>${s.data[1].toFixed(2)}%</div>`
  250. )
  251. .join(''),
  252. '</div>',
  253. `<div class="tooltip-footer">${intervalStart} &mdash; ${intervalEnd}</div>`,
  254. '<div class="tooltip-arrow"></div>',
  255. ].join('');
  256. },
  257. }}
  258. onClick={this.handleClick}
  259. />
  260. )}
  261. </ChartZoom>
  262. </TransitionChart>
  263. </PanelBody>
  264. <ChartFooter>
  265. <InlineContainer>
  266. <SectionHeading>
  267. {tct('Total [display]', {
  268. display:
  269. activeDisplay === ReleasesDisplayOption.USERS
  270. ? 'Users'
  271. : 'Sessions',
  272. })}
  273. </SectionHeading>
  274. <SectionValue>
  275. <Count value={totalCount || 0} />
  276. </SectionValue>
  277. </InlineContainer>
  278. </ChartFooter>
  279. </Panel>
  280. );
  281. }}
  282. </SessionsRequest>
  283. );
  284. }
  285. }
  286. export default withApi(ReleasesAdoptionChart);
  287. const ChartHeader = styled(HeaderTitleLegend)`
  288. margin-bottom: ${space(1)};
  289. `;
  290. const ChartTitle = styled('header')`
  291. display: flex;
  292. flex-direction: row;
  293. `;
  294. const ChartFooter = styled(PanelFooter)`
  295. display: flex;
  296. align-items: center;
  297. padding: ${space(1)} 20px;
  298. `;