utils.tsx 7.0 KB

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