Browse Source

feat(replay): Create a non-interactive breadcrumb timeline component (#33926)

Create an initial version of the breadcrumb timeline component.
This version doesn't have any of the interactive bits; mostly because they add to the complexity of EventStickMarker and need some help in the styling department.

This does include the primary bits of a timeline:

1. Major and minor gridlines
2. Labels across the x-axis for the major grid lines
3. Breadcrumb events are represented by 2px 'sticks' (for lack of a better name). If there are multiple events in the same timeslice, then they stack up vertically within that 2px space.
Ryan Albrecht 2 years ago
parent
commit
b1d09c1300

+ 57 - 0
docs-ui/stories/components/replays/example_breadcrumbs.json

@@ -0,0 +1,57 @@
+{
+  "values": [
+        {
+            "type": "default",
+            "timestamp": "2022-04-14T14:19:47.326000Z",
+            "level": "info",
+            "message": "d2ce61d15ac0466a846ab1cc1a19720a",
+            "category": "sentry.transaction",
+            "data": null,
+            "event_id": "d2ce61d15ac0466a846ab1cc1a19720a"
+        },
+        {
+            "type": "default",
+            "timestamp": "2022-04-14T14:19:49.249000Z",
+            "level": "info",
+            "message": "Content is cached for offline use.",
+            "category": "console",
+            "data": {
+                "arguments": [
+                    "Content is cached for offline use."
+                ],
+                "logger": "console"
+            },
+            "event_id": null
+        },
+        {
+            "type": "default",
+            "timestamp": "2022-04-14T14:19:51.512000Z",
+            "level": "info",
+            "message": "div > div.row > form > div.col-md-2 > button.btn.btn-default",
+            "category": "ui.click",
+            "data": null,
+            "event_id": null
+        },
+        {
+            "type": "default",
+            "timestamp": "2022-04-14T14:19:57.326000Z",
+            "level": "info",
+            "message": "div > div.row > form > div.col-md-2 > button.btn.btn-default",
+            "category": "ui.click",
+            "data": null,
+            "event_id": null
+        },
+        {
+            "type": "http",
+            "timestamp": "2022-04-14T14:20:13.036000Z",
+            "level": "error",
+            "message": null,
+            "category": "fetch",
+            "data": {
+                "method": "POST",
+                "url": "https://us-central1-sourcemapsio.cloudfunctions.net/validateGeneratedFile?url=http%3A%2F%2Fcode.jquery.com%2Fjquery-1.9.1.min.js"
+            },
+            "event_id": null
+        }
+    ]
+}

+ 42 - 0
docs-ui/stories/components/replays/replayBreadcrumbOverview.stories.js

@@ -0,0 +1,42 @@
+import styled from '@emotion/styled';
+
+import ReplayBreadcrumbOverview from 'sentry/components/replays/breadcrumbs/replayBreadcrumbOverview';
+import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
+import {EntryType} from 'sentry/types/event';
+
+// TODO these two sets of example data are not from the same replay!
+import breadcrumbs from './example_breadcrumbs.json';
+import rrwebEvents1 from './example_rrweb_events_1.json';
+
+export default {
+  title: 'Components/Replays/ReplayBreadcrumbOverview',
+  component: ReplayBreadcrumbOverview,
+};
+
+const ManualResize = styled('div')`
+  resize: both;
+  overflow: auto;
+  border: 1px solid ${p => p.theme.gray100};
+`;
+
+const Template = ({...args}) => (
+  <ReplayContextProvider value={{duration: 25710}} events={rrwebEvents1}>
+    <ManualResize>
+      <ReplayBreadcrumbOverview {...args} />
+    </ManualResize>
+  </ReplayContextProvider>
+);
+
+export const _ReplayBreadcrumbOverview = Template.bind({});
+_ReplayBreadcrumbOverview.args = {
+  data: breadcrumbs,
+  type: EntryType.BREADCRUMBS,
+};
+_ReplayBreadcrumbOverview.parameters = {
+  docs: {
+    description: {
+      story:
+        'ReplayBreadcrumbOverview is a component that contains play/pause buttons for the replay timeline',
+    },
+  },
+};

+ 150 - 0
static/app/components/replays/breadcrumbs/replayBreadcrumbOverview.tsx

@@ -0,0 +1,150 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
+import StackedContent from 'sentry/components/replays/stackedContent';
+import space from 'sentry/styles/space';
+import {Crumb, RawCrumb} from 'sentry/types/breadcrumbs';
+
+import {countColumns, formatTime, getCrumbsByColumn} from '../utils';
+
+const EVENT_STICK_MARKER_WIDTH = 2;
+
+interface Props {
+  data: {
+    values: Array<RawCrumb>;
+  };
+}
+
+type LineStyle = 'dotted' | 'solid' | 'none';
+
+function ReplayBreadcrumbOverview({data}: Props) {
+  const transformedCrumbs = transformCrumbs(data.values);
+
+  return (
+    <StackedContent>
+      {({width}) => (
+        <React.Fragment>
+          <Ticks
+            crumbs={transformedCrumbs}
+            width={width}
+            minWidth={20}
+            lineStyle="dotted"
+          />
+          <Ticks
+            crumbs={transformedCrumbs}
+            width={width}
+            showTimestamp
+            minWidth={50}
+            lineStyle="solid"
+          />
+          <Events crumbs={transformedCrumbs} width={width} />
+        </React.Fragment>
+      )}
+    </StackedContent>
+  );
+}
+
+function Ticks({
+  crumbs,
+  width,
+  minWidth = 50,
+  showTimestamp,
+  lineStyle = 'solid',
+}: {
+  crumbs: Crumb[];
+  lineStyle: LineStyle;
+  width: number;
+  minWidth?: number;
+  showTimestamp?: boolean;
+}) {
+  const startTime = crumbs[0]?.timestamp;
+  const endTime = crumbs[crumbs.length - 1]?.timestamp;
+
+  const startMilliSeconds = +new Date(String(startTime));
+  const endMilliSeconds = +new Date(String(endTime));
+
+  const duration = endMilliSeconds - startMilliSeconds;
+
+  const {timespan, cols, remaining} = countColumns(
+    isNaN(duration) ? 0 : duration,
+    width,
+    minWidth
+  );
+
+  return (
+    <TimelineMarkerList totalColumns={cols} remainder={remaining}>
+      {[...Array(cols)].map((_, i) => (
+        <TickMarker key={i} lineStyle={lineStyle}>
+          {showTimestamp && <small>{formatTime((i + 1) * timespan)}</small>}
+        </TickMarker>
+      ))}
+    </TimelineMarkerList>
+  );
+}
+
+const TimelineMarkerList = styled('ul')<{remainder: number; totalColumns: number}>`
+  /* Reset defaults for <ul> */
+  list-style: none;
+  margin: 0;
+  padding: 0;
+
+  height: 100%;
+  width: 100%;
+
+  /* Layout of the lines */
+  display: grid;
+  grid-template-columns: repeat(${p => p.totalColumns}, 1fr) ${p => p.remainder}fr;
+  place-items: stretch;
+`;
+
+const OffsetTimelineMarkerList = styled(TimelineMarkerList)`
+  padding-top: ${space(4)};
+`;
+
+const TickMarker = styled('li')<{lineStyle: LineStyle}>`
+  border-right: 2px ${p => p.lineStyle} ${p => p.theme.gray100};
+  text-align: right;
+`;
+
+function Events({crumbs, width}: {crumbs: Crumb[]; width: number}) {
+  const totalColumns = Math.floor(width / EVENT_STICK_MARKER_WIDTH);
+  const eventsByCol = getCrumbsByColumn(crumbs, totalColumns);
+
+  return (
+    <OffsetTimelineMarkerList totalColumns={totalColumns} remainder={0}>
+      {Array.from(eventsByCol.entries()).map(([column, breadcrumbs]) => (
+        <EventColumn key={column} column={column}>
+          <EventMarkerList breadcrumbs={breadcrumbs} />
+        </EventColumn>
+      ))}
+    </OffsetTimelineMarkerList>
+  );
+}
+
+function EventMarkerList({breadcrumbs}: {breadcrumbs: Crumb[]}) {
+  return (
+    <React.Fragment>
+      {breadcrumbs.map(breadcrumb => (
+        <EventStickMarker key={breadcrumb.timestamp} color={breadcrumb.color} />
+      ))}
+    </React.Fragment>
+  );
+}
+
+const EventColumn = styled('li')<{column: number}>`
+  grid-column: ${p => Math.floor(p.column)};
+  place-items: stretch;
+  height: 100%;
+  min-height: ${space(4)};
+  display: grid;
+`;
+
+const EventStickMarker = styled('div')<{color: string}>`
+  width: ${EVENT_STICK_MARKER_WIDTH}px;
+  background: ${p => p.theme[p.color] ?? p.color};
+  /* Size only applies to the inside, not the border */
+  box-sizing: content-box;
+`;
+
+export default ReplayBreadcrumbOverview;

+ 97 - 0
static/app/components/replays/utils.tsx

@@ -1,3 +1,5 @@
+import {Crumb} from 'sentry/types/breadcrumbs';
+
 function padZero(num: number, len = 2): string {
   let str = String(num);
   const threshold = Math.pow(10, len - 1);
@@ -28,3 +30,98 @@ export function formatTime(ms: number): string {
   }
   return `${minute}:${padZero(second)}`;
 }
+
+/**
+ * Figure out how many ticks to show in an area.
+ * If there is more space available, we can show more granular ticks, but if
+ * less space is available, fewer ticks.
+ * Similarly if the duration is short, the ticks will represent a short amount
+ * of time (like every second) but if the duration is long one tick may
+ * represent an hour.
+ *
+ * @param duration The amount of time that we need to chop up into even sections
+ * @param width Total width available, pixels
+ * @param minWidth Minimum space for each column, pixels. Ex: So we can show formatted time like `1:00:00` between major ticks
+ * @returns
+ */
+export function countColumns(duration: number, width: number, minWidth: number = 50) {
+  let maxCols = Math.floor(width / minWidth);
+  const remainder = duration - maxCols * width > 0 ? 1 : 0;
+  maxCols -= remainder;
+
+  // List of all the possible time granularities to display
+  // We could generate the list, which is basically a version of fizzbuzz, hard-coding is quicker.
+  const timeOptions = [
+    1 * HOUR,
+    30 * MINUTE,
+    20 * MINUTE,
+    15 * MINUTE,
+    10 * MINUTE,
+    5 * MINUTE,
+    2 * MINUTE,
+    1 * MINUTE,
+    30 * SECOND,
+    10 * SECOND,
+    5 * SECOND,
+    1 * SECOND,
+  ];
+
+  const timeBasedCols = timeOptions.reduce<Map<number, number>>((map, time) => {
+    map.set(time, Math.floor(duration / time));
+    return map;
+  }, new Map());
+
+  const [timespan, cols] = Array.from(timeBasedCols.entries())
+    .filter(([_span, c]) => c <= maxCols) // Filter for any valid timespan option where all ticks would fit
+    .reduce((best, next) => (next[1] > best[1] ? next : best), [0, 0]); // select the timespan option with the most ticks
+
+  const remaining = (duration - timespan * cols) / timespan;
+  return {timespan, cols, remaining};
+}
+
+/**
+ * Group Crumbs for display along the timeline.
+ *
+ * The timeline is broken down into columns (aka buckets, or time-slices).
+ * Columns translate to a fixed width on the screen, to prevent side-scrolling.
+ *
+ * This function groups crumbs into columns based on the number of columns available
+ * and the timestamp of the crumb.
+ */
+export function getCrumbsByColumn(crumbs: Crumb[], totalColumns: number) {
+  const startTime = crumbs[0]?.timestamp;
+  const endTime = crumbs[crumbs.length - 1]?.timestamp;
+
+  // If there is only one crumb then we cannot do the math, return it in the first column
+  if (crumbs.length === 1 || startTime === endTime) {
+    return new Map([[0, crumbs]]);
+  }
+
+  const startMilliSeconds = +new Date(String(startTime));
+  const endMilliSeconds = +new Date(String(endTime));
+
+  const duration = endMilliSeconds - startMilliSeconds;
+  const safeDuration = isNaN(duration) ? 1 : duration;
+
+  const columnCrumbPairs = crumbs.map(breadcrumb => {
+    const {timestamp} = breadcrumb;
+    const timestampMilliSeconds = +new Date(String(timestamp));
+    const sinceStart = isNaN(timestampMilliSeconds)
+      ? 0
+      : timestampMilliSeconds - startMilliSeconds;
+    const column = Math.floor((sinceStart / safeDuration) * (totalColumns - 1)) + 1;
+
+    return [column, breadcrumb] as [number, Crumb];
+  });
+
+  const crumbsByColumn = columnCrumbPairs.reduce((map, [column, breadcrumb]) => {
+    if (map.has(column)) {
+      map.get(column)?.push(breadcrumb);
+    } else {
+      map.set(column, [breadcrumb]);
+    }
+    return map;
+  }, new Map() as Map<number, Crumb[]>);
+
+  return crumbsByColumn;
+}

+ 2 - 0
static/app/views/replays/details.tsx

@@ -11,6 +11,7 @@ import EventMessage from 'sentry/components/events/eventMessage';
 import FeatureBadge from 'sentry/components/featureBadge';
 import * as Layout from 'sentry/components/layouts/thirds';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import ReplayBreadcrumbOverview from 'sentry/components/replays/breadcrumbs/replayBreadcrumbOverview';
 import {Provider as ReplayContextProvider} from 'sentry/components/replays/replayContext';
 import ReplayController from 'sentry/components/replays/replayController';
 import ReplayPlayer from 'sentry/components/replays/replayPlayer';
@@ -180,6 +181,7 @@ function ReplayLoader(props: ReplayLoaderProps) {
             to full up, on a page that scrolls we only consider the max-width. */}
             <ReplayPlayer fixedHeight={isFullscreen} />
             <ReplayController toggleFullscreen={toggleFullscreen} />
+            {breadcrumbEntry && <ReplayBreadcrumbOverview data={breadcrumbEntry.data} />}
           </FullscreenWrapper>
         </ReplayContextProvider>
 

+ 116 - 0
tests/js/spec/components/replays/utils.spec.jsx

@@ -0,0 +1,116 @@
+import {
+  countColumns,
+  formatTime,
+  getCrumbsByColumn,
+} from 'sentry/components/replays/utils';
+
+const SECOND = 1000;
+
+describe('formatTime', () => {
+  it.each([
+    ['seconds', 15 * 1000, '0:15'],
+    ['minutes', 2.5 * 60 * 1000, '2:30'],
+    ['hours', 75 * 60 * 1000, '1:15:00'],
+  ])('should format a %s long duration into a string', (_desc, duration, expected) => {
+    expect(formatTime(duration)).toEqual(expected);
+  });
+});
+
+describe('countColumns', () => {
+  it('should divide 27s by 2700px to find twentyseven 1s columns, with some fraction remaining', () => {
+    // 2700 allows for up to 27 columns at 100px wide.
+    // That is what we'd need if we were to render at `1s` granularity, so we can.
+    const width = 2700;
+
+    const duration = 27 * SECOND;
+    const minWidth = 100;
+    const {timespan, cols, remaining} = countColumns(duration, width, minWidth);
+
+    expect(timespan).toBe(1 * SECOND);
+    expect(cols).toBe(27);
+    expect(remaining).toBe(0);
+  });
+
+  it('should divide 27s by 2699px to find five 5s columns, with some fraction remaining', () => {
+    // 2699px allows for up to 26 columns at 100px wide, with 99px leftover.
+    // That is less than the 27 cols we'd need if we were to render at `1s` granularity.
+    // So instead we get 5 cols (wider than 100px) at 5s granularity, and some extra space is remaining.
+    const width = 2699;
+
+    const duration = 27 * SECOND;
+    const minWidth = 100;
+    const {timespan, cols, remaining} = countColumns(duration, width, minWidth);
+
+    expect(timespan).toBe(5 * SECOND);
+    expect(cols).toBe(5);
+    expect(remaining).toBe(0.4);
+  });
+
+  it('should divide 27s by 600px to find five 5s columns, with some fraction column remaining', () => {
+    // 600px allows for 6 columns at 100px wide to fix within it
+    // That allows us to get 5 cols (100px wide) at 5s granularity, and an extra 100px for the remainder
+    const width = 600;
+
+    const duration = 27 * SECOND;
+    const minWidth = 100;
+    const {timespan, cols, remaining} = countColumns(duration, width, minWidth);
+
+    expect(timespan).toBe(5 * SECOND);
+    expect(cols).toBe(5);
+    expect(remaining).toBe(0.4);
+  });
+
+  it('should divide 27s by 599px to find five 2s columns, with some fraction column remaining', () => {
+    // 599px allows for 5 columns at 100px wide, and 99px remaining.
+    // That allows us to get 2 cols (100px wide) at 10s granularity, and an extra px for the remainder
+    const width = 599;
+
+    const duration = 27 * SECOND;
+    const minWidth = 100;
+    const {timespan, cols, remaining} = countColumns(duration, width, minWidth);
+
+    expect(timespan).toBe(10 * SECOND);
+    expect(cols).toBe(2);
+    expect(remaining).toBe(0.7);
+  });
+});
+
+describe('getCrumbsByColumn', () => {
+  const CRUMB_1 = {timestamp: '2022-04-14T14:19:47.326000Z'};
+  const CRUMB_2 = {timestamp: '2022-04-14T14:19:49.249000Z'};
+  const CRUMB_3 = {timestamp: '2022-04-14T14:19:51.512000Z'};
+  const CRUMB_4 = {timestamp: '2022-04-14T14:19:57.326000Z'};
+  const CRUMB_5 = {timestamp: '2022-04-14T14:20:13.036000Z'};
+
+  it('should return a map of buckets', () => {
+    const columnCount = 3;
+    const columns = getCrumbsByColumn([], columnCount);
+    const expectedEntries = [[0, []]];
+    expect(columns).toEqual(new Map(expectedEntries));
+  });
+
+  it('should put a crumbs in the first and last buckets', () => {
+    const columnCount = 3;
+    const columns = getCrumbsByColumn([CRUMB_1, CRUMB_5], columnCount);
+    const expectedEntries = [
+      [1, [CRUMB_1]],
+      [3, [CRUMB_5]],
+    ];
+    expect(columns).toEqual(new Map(expectedEntries));
+  });
+
+  it('should group crumbs by bucket', () => {
+    // 6 columns gives is 5s granularity
+    const columnCount = 6;
+    const columns = getCrumbsByColumn(
+      [CRUMB_1, CRUMB_2, CRUMB_3, CRUMB_4, CRUMB_5],
+      columnCount
+    );
+    const expectedEntries = [
+      [1, [CRUMB_1, CRUMB_2, CRUMB_3]],
+      [2, [CRUMB_4]],
+      [6, [CRUMB_5]],
+    ];
+    expect(columns).toEqual(new Map(expectedEntries));
+  });
+});