utils.tsx 6.6 KB

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