utils.tsx 6.6 KB

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