utils.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import moment from 'moment';
  2. import {Crumb} from 'sentry/types/breadcrumbs';
  3. import type {ReplaySpan} from 'sentry/views/replays/types';
  4. function padZero(num: number, len = 2): string {
  5. let str = String(num);
  6. const threshold = Math.pow(10, len - 1);
  7. if (num < threshold) {
  8. while (String(threshold).length > str.length) {
  9. str = '0' + num;
  10. }
  11. }
  12. return str;
  13. }
  14. const SECOND = 1000;
  15. const MINUTE = 60 * SECOND;
  16. const HOUR = 60 * MINUTE;
  17. /**
  18. * @param timestamp The timestamp that is our reference point. Can be anything that `moment` accepts such as `'2022-05-04T19:47:52.915000Z'` or `1651664872.915`
  19. * @param diffMs Number of milliseconds to adjust the timestamp by, either positive (future) or negative (past)
  20. * @returns Unix timestamp of the adjusted timestamp, in milliseconds
  21. */
  22. export function relativeTimeInMs(timestamp: moment.MomentInput, diffMs: number): number {
  23. return moment(timestamp).diff(moment.unix(diffMs)).valueOf();
  24. }
  25. export function showPlayerTime(
  26. timestamp: moment.MomentInput,
  27. relativeTime: number
  28. ): string {
  29. return formatTime(relativeTimeInMs(timestamp, relativeTime));
  30. }
  31. // TODO: move into 'sentry/utils/formatters'
  32. export function formatTime(ms: number): string {
  33. if (ms <= 0 || isNaN(ms)) {
  34. return '00:00';
  35. }
  36. const hour = Math.floor(ms / HOUR);
  37. ms = ms % HOUR;
  38. const minute = Math.floor(ms / MINUTE);
  39. ms = ms % MINUTE;
  40. const second = Math.floor(ms / SECOND);
  41. if (hour) {
  42. return `${padZero(hour)}:${padZero(minute)}:${padZero(second)}`;
  43. }
  44. return `${padZero(minute)}:${padZero(second)}`;
  45. }
  46. /**
  47. * Figure out how many ticks to show in an area.
  48. * If there is more space available, we can show more granular ticks, but if
  49. * less space is available, fewer ticks.
  50. * Similarly if the duration is short, the ticks will represent a short amount
  51. * of time (like every second) but if the duration is long one tick may
  52. * represent an hour.
  53. *
  54. * @param duration The amount of time that we need to chop up into even sections
  55. * @param width Total width available, pixels
  56. * @param minWidth Minimum space for each column, pixels. Ex: So we can show formatted time like `1:00:00` between major ticks
  57. * @returns
  58. */
  59. export function countColumns(duration: number, width: number, minWidth: number = 50) {
  60. let maxCols = Math.floor(width / minWidth);
  61. const remainder = duration - maxCols * width > 0 ? 1 : 0;
  62. maxCols -= remainder;
  63. // List of all the possible time granularities to display
  64. // We could generate the list, which is basically a version of fizzbuzz, hard-coding is quicker.
  65. const timeOptions = [
  66. 1 * HOUR,
  67. 30 * MINUTE,
  68. 20 * MINUTE,
  69. 15 * MINUTE,
  70. 10 * MINUTE,
  71. 5 * MINUTE,
  72. 2 * MINUTE,
  73. 1 * MINUTE,
  74. 30 * SECOND,
  75. 10 * SECOND,
  76. 5 * SECOND,
  77. 1 * SECOND,
  78. ];
  79. const timeBasedCols = timeOptions.reduce<Map<number, number>>((map, time) => {
  80. map.set(time, Math.floor(duration / time));
  81. return map;
  82. }, new Map());
  83. const [timespan, cols] = Array.from(timeBasedCols.entries())
  84. .filter(([_span, c]) => c <= maxCols) // Filter for any valid timespan option where all ticks would fit
  85. .reduce((best, next) => (next[1] > best[1] ? next : best), [0, 0]); // select the timespan option with the most ticks
  86. const remaining = (duration - timespan * cols) / timespan;
  87. return {timespan, cols, remaining};
  88. }
  89. /**
  90. * Group Crumbs for display along the timeline.
  91. *
  92. * The timeline is broken down into columns (aka buckets, or time-slices).
  93. * Columns translate to a fixed width on the screen, to prevent side-scrolling.
  94. *
  95. * This function groups crumbs into columns based on the number of columns available
  96. * and the timestamp of the crumb.
  97. */
  98. export function getCrumbsByColumn(
  99. startTimestamp: number,
  100. duration: number,
  101. crumbs: Crumb[],
  102. totalColumns: number
  103. ) {
  104. const startMilliSeconds = startTimestamp * 1000;
  105. const safeDuration = isNaN(duration) ? 1 : duration;
  106. const columnCrumbPairs = crumbs.map(breadcrumb => {
  107. const {timestamp} = breadcrumb;
  108. const timestampMilliSeconds = +new Date(String(timestamp));
  109. const sinceStart = isNaN(timestampMilliSeconds)
  110. ? 0
  111. : timestampMilliSeconds - startMilliSeconds;
  112. const column = Math.floor((sinceStart / safeDuration) * (totalColumns - 1)) + 1;
  113. return [column, breadcrumb] as [number, Crumb];
  114. });
  115. const crumbsByColumn = columnCrumbPairs.reduce((map, [column, breadcrumb]) => {
  116. if (map.has(column)) {
  117. map.get(column)?.push(breadcrumb);
  118. } else {
  119. map.set(column, [breadcrumb]);
  120. }
  121. return map;
  122. }, new Map() as Map<number, Crumb[]>);
  123. return crumbsByColumn;
  124. }
  125. type FlattenedSpanRange = {
  126. /**
  127. * Duration of this range
  128. */
  129. duration: number;
  130. /**
  131. * Absolute time in ms when the range ends
  132. */
  133. endTimestamp: number;
  134. /**
  135. * Number of spans that got flattened into this range
  136. */
  137. spanCount: number;
  138. /**
  139. * ID of the original span that created this range
  140. */
  141. spanId: string;
  142. //
  143. /**
  144. * Absolute time in ms when the span starts
  145. */
  146. startTimestamp: number;
  147. };
  148. function doesOverlap(a: FlattenedSpanRange, b: FlattenedSpanRange) {
  149. const bStartsWithinA =
  150. a.startTimestamp <= b.startTimestamp && b.startTimestamp <= a.endTimestamp;
  151. const bEndsWithinA =
  152. a.startTimestamp <= b.endTimestamp && b.endTimestamp <= a.endTimestamp;
  153. return bStartsWithinA || bEndsWithinA;
  154. }
  155. export function flattenSpans(rawSpans: ReplaySpan[]): FlattenedSpanRange[] {
  156. if (!rawSpans.length) {
  157. return [];
  158. }
  159. const spans = rawSpans.map(span => {
  160. const startTimestamp = span.startTimestamp * 1000;
  161. // `endTimestamp` is at least msPerPixel wide, otherwise it disappears
  162. const endTimestamp = span.endTimestamp * 1000;
  163. return {
  164. spanCount: 1,
  165. // spanId: span.span_id,
  166. startTimestamp,
  167. endTimestamp,
  168. duration: endTimestamp - startTimestamp,
  169. } as FlattenedSpanRange;
  170. });
  171. const [firstSpan, ...restSpans] = spans;
  172. const flatSpans = [firstSpan];
  173. for (const span of restSpans) {
  174. let overlap = false;
  175. for (const fspan of flatSpans) {
  176. if (doesOverlap(fspan, span)) {
  177. overlap = true;
  178. fspan.spanCount += 1;
  179. fspan.startTimestamp = Math.min(fspan.startTimestamp, span.startTimestamp);
  180. fspan.endTimestamp = Math.max(fspan.endTimestamp, span.endTimestamp);
  181. fspan.duration = fspan.endTimestamp - fspan.startTimestamp;
  182. break;
  183. }
  184. }
  185. if (!overlap) {
  186. flatSpans.push(span);
  187. }
  188. }
  189. return flatSpans;
  190. }
  191. /**
  192. * Divide two numbers safely
  193. */
  194. export function divide(numerator: number, denominator: number | undefined) {
  195. if (denominator === undefined || isNaN(denominator) || denominator === 0) {
  196. return 0;
  197. }
  198. return numerator / denominator;
  199. }