sessions.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import compact from 'lodash/compact';
  2. import mean from 'lodash/mean';
  3. import moment from 'moment';
  4. import {
  5. DateTimeObject,
  6. getDiffInMinutes,
  7. SIX_HOURS,
  8. SIXTY_DAYS,
  9. THIRTY_DAYS,
  10. } from 'sentry/components/charts/utils';
  11. import {SessionApiResponse, SessionFieldWithOperation, SessionStatus} from 'sentry/types';
  12. import {SeriesDataUnit} from 'sentry/types/echarts';
  13. import {defined, percent} from 'sentry/utils';
  14. import {Theme} from 'sentry/utils/theme';
  15. import {getCrashFreePercent, getSessionStatusPercent} from 'sentry/views/releases/utils';
  16. import {sessionTerm} from 'sentry/views/releases/utils/sessionTerm';
  17. /**
  18. * If the time window is less than or equal 10, seconds will be displayed on the graphs
  19. */
  20. export const MINUTES_THRESHOLD_TO_DISPLAY_SECONDS = 10;
  21. export function getCount(
  22. groups: SessionApiResponse['groups'] = [],
  23. field: SessionFieldWithOperation
  24. ) {
  25. return groups.reduce((acc, group) => acc + group.totals[field], 0);
  26. }
  27. export function getCountAtIndex(
  28. groups: SessionApiResponse['groups'] = [],
  29. field: SessionFieldWithOperation,
  30. index: number
  31. ) {
  32. return groups.reduce((acc, group) => acc + group.series[field][index], 0);
  33. }
  34. export function getCrashFreeRate(
  35. groups: SessionApiResponse['groups'] = [],
  36. field: SessionFieldWithOperation
  37. ) {
  38. const crashedRate = getSessionStatusRate(groups, field, SessionStatus.CRASHED);
  39. return defined(crashedRate) ? getCrashFreePercent(100 - crashedRate) : null;
  40. }
  41. export function getSeriesAverage(
  42. groups: SessionApiResponse['groups'] = [],
  43. field: SessionFieldWithOperation
  44. ) {
  45. const totalCount = getCount(groups, field);
  46. const dataPoints = groups.filter(group => !!group.totals[field]).length;
  47. return !defined(totalCount) || dataPoints === null || totalCount === 0
  48. ? null
  49. : totalCount / dataPoints;
  50. }
  51. export function getSeriesSum(
  52. groups: SessionApiResponse['groups'] = [],
  53. field: SessionFieldWithOperation,
  54. intervals: SessionApiResponse['intervals'] = []
  55. ) {
  56. const dataPointsSums: number[] = Array(intervals.length).fill(0);
  57. const groupSeries = groups.map(group => group.series[field]);
  58. groupSeries.forEach(series => {
  59. series.forEach((dataPoint, idx) => (dataPointsSums[idx] += dataPoint));
  60. });
  61. return dataPointsSums;
  62. }
  63. export function getSessionStatusRate(
  64. groups: SessionApiResponse['groups'] = [],
  65. field: SessionFieldWithOperation,
  66. status: SessionStatus
  67. ) {
  68. const totalCount = getCount(groups, field);
  69. const crashedCount = getCount(
  70. groups.filter(({by}) => by['session.status'] === status),
  71. field
  72. );
  73. return !defined(totalCount) || totalCount === 0
  74. ? null
  75. : percent(crashedCount ?? 0, totalCount ?? 0);
  76. }
  77. export function getCrashFreeRateSeries(
  78. groups: SessionApiResponse['groups'] = [],
  79. intervals: SessionApiResponse['intervals'] = [],
  80. field: SessionFieldWithOperation
  81. ): SeriesDataUnit[] {
  82. return compact(
  83. intervals.map((interval, i) => {
  84. const intervalTotalSessions = groups.reduce(
  85. (acc, group) => acc + (group.series[field]?.[i] ?? 0),
  86. 0
  87. );
  88. const intervalCrashedSessions =
  89. groups.find(group => group.by['session.status'] === SessionStatus.CRASHED)
  90. ?.series[field]?.[i] ?? 0;
  91. const crashedSessionsPercent = percent(
  92. intervalCrashedSessions,
  93. intervalTotalSessions
  94. );
  95. if (intervalTotalSessions === 0) {
  96. return null;
  97. }
  98. return {
  99. name: interval,
  100. value: getCrashFreePercent(100 - crashedSessionsPercent),
  101. };
  102. })
  103. );
  104. }
  105. export function getSessionStatusRateSeries(
  106. groups: SessionApiResponse['groups'] = [],
  107. intervals: SessionApiResponse['intervals'] = [],
  108. field: SessionFieldWithOperation,
  109. status: SessionStatus
  110. ): SeriesDataUnit[] {
  111. return compact(
  112. intervals.map((interval, i) => {
  113. const intervalTotalSessions = groups.reduce(
  114. (acc, group) => acc + group.series[field][i],
  115. 0
  116. );
  117. const intervalStatusSessions =
  118. groups.find(group => group.by['session.status'] === status)?.series[field][i] ??
  119. 0;
  120. const statusSessionsPercent = percent(
  121. intervalStatusSessions,
  122. intervalTotalSessions
  123. );
  124. if (intervalTotalSessions === 0) {
  125. return null;
  126. }
  127. return {
  128. name: interval,
  129. value: getSessionStatusPercent(statusSessionsPercent),
  130. };
  131. })
  132. );
  133. }
  134. export function getSessionP50Series(
  135. groups: SessionApiResponse['groups'] = [],
  136. intervals: SessionApiResponse['intervals'] = [],
  137. field: SessionFieldWithOperation,
  138. valueFormatter?: (value: number) => number
  139. ): SeriesDataUnit[] {
  140. return compact(
  141. intervals.map((interval, i) => {
  142. const meanValue = mean(
  143. groups.map(group => group.series[field][i]).filter(v => !!v)
  144. );
  145. if (!meanValue) {
  146. return null;
  147. }
  148. return {
  149. name: interval,
  150. value:
  151. typeof valueFormatter === 'function' ? valueFormatter(meanValue) : meanValue,
  152. };
  153. })
  154. );
  155. }
  156. export function getAdoptionSeries(
  157. releaseGroups: SessionApiResponse['groups'] = [],
  158. allGroups: SessionApiResponse['groups'] = [],
  159. intervals: SessionApiResponse['intervals'] = [],
  160. field: SessionFieldWithOperation
  161. ): SeriesDataUnit[] {
  162. return intervals.map((interval, i) => {
  163. const intervalReleaseSessions = releaseGroups.reduce(
  164. (acc, group) => acc + (group.series[field]?.[i] ?? 0),
  165. 0
  166. );
  167. const intervalTotalSessions = allGroups.reduce(
  168. (acc, group) => acc + (group.series[field]?.[i] ?? 0),
  169. 0
  170. );
  171. const intervalAdoption = percent(intervalReleaseSessions, intervalTotalSessions);
  172. return {
  173. name: interval,
  174. value: Math.round(intervalAdoption),
  175. };
  176. });
  177. }
  178. export function getCountSeries(
  179. field: SessionFieldWithOperation,
  180. group?: SessionApiResponse['groups'][0],
  181. intervals: SessionApiResponse['intervals'] = []
  182. ): SeriesDataUnit[] {
  183. return intervals.map((interval, index) => ({
  184. name: interval,
  185. value: group?.series[field][index] ?? 0,
  186. }));
  187. }
  188. export function initSessionsChart(theme: Theme) {
  189. const colors = theme.charts.getColorPalette(14);
  190. return {
  191. [SessionStatus.HEALTHY]: {
  192. seriesName: sessionTerm.healthy,
  193. data: [],
  194. color: theme.green300,
  195. areaStyle: {
  196. color: theme.green300,
  197. opacity: 1,
  198. },
  199. lineStyle: {
  200. opacity: 0,
  201. width: 0.4,
  202. },
  203. },
  204. [SessionStatus.ERRORED]: {
  205. seriesName: sessionTerm.errored,
  206. data: [],
  207. color: colors[12],
  208. areaStyle: {
  209. color: colors[12],
  210. opacity: 1,
  211. },
  212. lineStyle: {
  213. opacity: 0,
  214. width: 0.4,
  215. },
  216. },
  217. [SessionStatus.ABNORMAL]: {
  218. seriesName: sessionTerm.abnormal,
  219. data: [],
  220. color: colors[15],
  221. areaStyle: {
  222. color: colors[15],
  223. opacity: 1,
  224. },
  225. lineStyle: {
  226. opacity: 0,
  227. width: 0.4,
  228. },
  229. },
  230. [SessionStatus.CRASHED]: {
  231. seriesName: sessionTerm.crashed,
  232. data: [],
  233. color: theme.red300,
  234. areaStyle: {
  235. color: theme.red300,
  236. opacity: 1,
  237. },
  238. lineStyle: {
  239. opacity: 0,
  240. width: 0.4,
  241. },
  242. },
  243. };
  244. }
  245. type GetSessionsIntervalOptions = {
  246. highFidelity?: boolean;
  247. };
  248. export function getSessionsInterval(
  249. datetimeObj: DateTimeObject,
  250. {highFidelity}: GetSessionsIntervalOptions = {}
  251. ) {
  252. const diffInMinutes = getDiffInMinutes(datetimeObj);
  253. if (moment(datetimeObj.start).isSameOrBefore(moment().subtract(30, 'days'))) {
  254. // we cannot use sub-hour session resolution on buckets older than 30 days
  255. highFidelity = false;
  256. }
  257. if (diffInMinutes >= SIXTY_DAYS) {
  258. return '1d';
  259. }
  260. if (diffInMinutes >= THIRTY_DAYS) {
  261. return '4h';
  262. }
  263. if (diffInMinutes >= SIX_HOURS) {
  264. return '1h';
  265. }
  266. // limit on backend for sub-hour session resolution is set to six hours
  267. if (highFidelity) {
  268. if (diffInMinutes <= MINUTES_THRESHOLD_TO_DISPLAY_SECONDS) {
  269. // This only works for metrics-based session stats.
  270. // Backend will silently replace with '1m' for session-based stats.
  271. return '10s';
  272. }
  273. if (diffInMinutes <= 30) {
  274. return '1m';
  275. }
  276. return '5m';
  277. }
  278. return '1h';
  279. }
  280. // Sessions API can only round intervals to the closest hour - this is especially problematic when using sub-hour resolution.
  281. // We filter out results that are out of bounds on frontend and recalculate totals.
  282. export function filterSessionsInTimeWindow(
  283. sessions: SessionApiResponse,
  284. start?: string,
  285. end?: string
  286. ) {
  287. if (!start || !end) {
  288. return sessions;
  289. }
  290. const filteredIndexes: number[] = [];
  291. const intervals = sessions.intervals.filter((interval, index) => {
  292. const isBetween = moment
  293. .utc(interval)
  294. .isBetween(moment.utc(start), moment.utc(end), undefined, '[]');
  295. if (isBetween) {
  296. filteredIndexes.push(index);
  297. }
  298. return isBetween;
  299. });
  300. const groups = sessions.groups.map(group => {
  301. const series = {};
  302. const totals = {};
  303. Object.keys(group.series).forEach(field => {
  304. totals[field] = 0;
  305. series[field] = group.series[field].filter((value, index) => {
  306. const isBetween = filteredIndexes.includes(index);
  307. if (isBetween) {
  308. totals[field] = (totals[field] ?? 0) + value;
  309. }
  310. return isBetween;
  311. });
  312. if (field.startsWith('p50')) {
  313. totals[field] = mean(series[field]);
  314. }
  315. if (field.startsWith('count_unique')) {
  316. /* E.g. users
  317. We cannot sum here because users would not be unique anymore.
  318. User can be repeated and part of multiple buckets in series but it's still that one user so totals would be wrong.
  319. This operation is not 100% correct, because we are filtering series in time window but the total is for unfiltered series (it's the closest thing we can do right now) */
  320. totals[field] = group.totals[field];
  321. }
  322. });
  323. return {...group, series, totals};
  324. });
  325. return {
  326. start: intervals[0],
  327. end: intervals[intervals.length - 1],
  328. query: sessions.query,
  329. intervals,
  330. groups,
  331. };
  332. }