Browse Source

fix(replays): Adjust replay duration based on startedAt/finishedAt (#40281)

Prevent rendering from overflowing the Timeline component by
re-computing `duration` based on our calculated `startedAt` and
`finishedAt` fields.

| Before | After |
| --- | --- |
| <img width="101" alt="Screen Shot 2022-10-19 at 12 00 33 PM"
src="https://user-images.githubusercontent.com/187460/196796712-9f88efef-513c-445e-b48f-21b18f49b7a5.png">
| <img width="94" alt="Screen Shot 2022-10-19 at 1 18 25 PM"
src="https://user-images.githubusercontent.com/187460/196796556-3cf9f641-fdc8-48fb-9deb-6b64367b7a6b.png">
|

If you look really carefully you'll notice that the 'after' screen shot
includes a dotted gridline which marks the `02:55` position. The
Timeline in this case is rendering from `t=0` to `t=2:55.398` which is
just enough for it to render that extra gridline.

Clicking and hovering the last console message, which happened at
`18:53:59.397Z` shows no overflow in the timeline.

This is similar to, but doesn't conflict with #40272
Ryan Albrecht 2 years ago
parent
commit
81fd9675a1

+ 21 - 11
src/sentry/replays/testutils.py

@@ -21,6 +21,15 @@ SegmentList = typing.Iterable[typing.Dict[str, typing.Any]]
 RRWebNode = typing.Dict[str, typing.Any]
 
 
+def sec(timestamp: datetime.datetime):
+    # sentry data inside rrweb is recorded in seconds
+    return int(timestamp.timestamp())
+
+
+def ms(timestamp: datetime.datetime):
+    return int(timestamp.timestamp()) * 1000
+
+
 def assert_expected_response(
     response: typing.Dict[str, typing.Any], expected_response: typing.Dict[str, typing.Any]
 ) -> None:
@@ -115,7 +124,7 @@ def mock_replay(
 
     return {
         "type": "replay_event",
-        "start_time": int(timestamp.timestamp()),
+        "start_time": sec(timestamp),
         "replay_id": replay_id,
         "project_id": project_id,
         "retention_days": 30,
@@ -137,9 +146,9 @@ def mock_replay(
                         ),
                         "dist": kwargs.pop("dist", "abc123"),
                         "platform": kwargs.pop("platform", "javascript"),
-                        "timestamp": int(timestamp.timestamp()),
+                        "timestamp": sec(timestamp),
                         "replay_start_timestamp": kwargs.pop(
-                            "replay_start_timestamp", int(timestamp.timestamp())
+                            "replay_start_timestamp", sec(timestamp)
                         ),
                         "environment": kwargs.pop("environment", "production"),
                         "release": kwargs.pop("release", "version@1.3"),
@@ -197,16 +206,16 @@ def mock_segment_init(
     return [
         {
             "type": EventType.DomContentLoaded,
-            "timestamp": int(timestamp.timestamp()),
+            "timestamp": ms(timestamp),  # rrweb timestamps are in ms
         },
         {
             "type": EventType.Load,
-            "timestamp": int(timestamp.timestamp()),
+            "timestamp": ms(timestamp),  # rrweb timestamps are in ms
         },
         {
             "type": EventType.Meta,
             "data": {"href": href, "width": width, "height": height},
-            "timestamp": int(timestamp.timestamp()),
+            "timestamp": ms(timestamp),  # rrweb timestamps are in ms
         },
     ]
 
@@ -223,11 +232,12 @@ def mock_segment_fullsnapshot(timestamp: datetime.datetime, bodyChildNodes) -> S
         tagName="html",
         childNodes=[bodyNode],
     )
+
     return [
         {
             "type": EventType.FullSnapshot,
             "data": {
-                "timestamp": int(timestamp.timestamp()),
+                "timestamp": ms(timestamp),  # rrweb timestamps are in ms
                 "node": {
                     "type": EventType.DomContentLoaded,
                     "childNodes": [htmlNode],
@@ -241,11 +251,11 @@ def mock_segment_console(timestamp: datetime.datetime) -> SegmentList:
     return [
         {
             "type": EventType.Custom,
-            "timestamp": int(timestamp.timestamp()),
+            "timestamp": ms(timestamp),  # rrweb timestamps are in ms
             "data": {
                 "tag": "breadcrumb",
                 "payload": {
-                    "timestamp": int(timestamp.timestamp()) / 1000,
+                    "timestamp": sec(timestamp),  # sentry data inside rrweb is in seconds
                     "type": "default",
                     "category": "console",
                     "data": {
@@ -266,7 +276,7 @@ def mock_segment_breadcrumb(timestamp: datetime.datetime, payload) -> SegmentLis
     return [
         {
             "type": 5,
-            "timestamp": int(timestamp.timestamp()),
+            "timestamp": ms(timestamp),  # rrweb timestamps are in ms
             "data": {
                 "tag": "breadcrumb",
                 "payload": payload,
@@ -281,7 +291,7 @@ def mock_segment_nagivation(
     return mock_segment_breadcrumb(
         timestamp,
         {
-            "timestamp": int(timestamp.timestamp()) / 1000,
+            "timestamp": sec(timestamp),  # sentry data inside rrweb is in seconds
             "type": "default",
             "category": "navigation",
             "data": {"from": hrefFrom, "to": hrefTo},

+ 4 - 3
static/app/components/replays/replayHighlight.tsx

@@ -13,14 +13,15 @@ function ReplayHighlight({replay}: Props) {
 
   if (replay) {
     const {countErrors, duration, urls} = replay;
+    const durationSec = duration.asSeconds();
     const pagesVisited = urls.length;
 
-    const pagesVisitedOverTime = pagesVisited / (duration || 1);
+    const pagesVisitedOverTime = pagesVisited / (durationSec || 1);
 
     score = (countErrors * 25 + pagesVisited * 5 + pagesVisitedOverTime) / 10;
     // negatively score sub 5 second replays
-    if (duration <= 5) {
-      score = score - 10 / (duration || 1);
+    if (durationSec <= 5) {
+      score = score - 10 / (durationSec || 1);
     }
 
     score = Math.floor(Math.min(10, Math.max(1, score)));

+ 2 - 0
static/app/utils/replays/replayDataUtils.tsx

@@ -1,4 +1,5 @@
 import first from 'lodash/first';
+import {duration} from 'moment';
 
 import {transformCrumbs} from 'sentry/components/events/interfaces/breadcrumbs/utils';
 import {t} from 'sentry/locale';
@@ -69,6 +70,7 @@ export function mapResponseToReplayRecord(apiResponse: any): ReplayRecord {
     ...apiResponse,
     ...(apiResponse.startedAt ? {startedAt: new Date(apiResponse.startedAt)} : {}),
     ...(apiResponse.finishedAt ? {finishedAt: new Date(apiResponse.finishedAt)} : {}),
+    ...(apiResponse.duration ? {duration: duration(apiResponse.duration * 1000)} : {}),
     tags,
   };
 }

+ 6 - 1
static/app/utils/replays/replayReader.tsx

@@ -1,3 +1,5 @@
+import {duration} from 'moment';
+
 import type {Crumb} from 'sentry/types/breadcrumbs';
 import {
   breadcrumbFactory,
@@ -71,6 +73,9 @@ export default class ReplayReader {
     );
     replayRecord.startedAt = new Date(startTimestampMs);
     replayRecord.finishedAt = new Date(endTimestampMs);
+    replayRecord.duration = duration(
+      replayRecord.finishedAt.getTime() - replayRecord.startedAt.getTime()
+    );
 
     const sortedSpans = spansFactory(spans);
     this.networkSpans = sortedSpans.filter(isNetworkSpan);
@@ -95,7 +100,7 @@ export default class ReplayReader {
    * @returns Duration of Replay (milliseonds)
    */
   getDurationMs = () => {
-    return this.replayRecord.duration * 1000;
+    return this.replayRecord.duration.asMilliseconds();
   };
 
   getReplay = () => {

+ 5 - 1
static/app/views/replays/detail/replayMetaData.tsx

@@ -61,7 +61,11 @@ function ReplayMetaData({replayRecord}: Props) {
         {replayRecord ? (
           <Fragment>
             <IconClock color="gray300" />
-            <Duration seconds={replayRecord?.duration} abbreviation exact />
+            <Duration
+              seconds={Math.trunc(replayRecord?.duration.asSeconds())}
+              abbreviation
+              exact
+            />
           </Fragment>
         ) : (
           <HeaderPlaceholder />

+ 1 - 1
static/app/views/replays/replayTable.tsx

@@ -258,7 +258,7 @@ function ReplayTableRow({
         </Item>
       )}
       <Item>
-        <Duration seconds={Math.floor(replay.duration)} exact abbreviation />
+        <Duration seconds={replay.duration.asSeconds()} exact abbreviation />
       </Item>
       <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
       <Item>

+ 3 - 2
static/app/views/replays/types.tsx

@@ -1,3 +1,4 @@
+import type {Duration} from 'moment';
 import type {eventWithTime} from 'rrweb/typings/types';
 
 import type {RawCrumb} from 'sentry/types/breadcrumbs';
@@ -29,9 +30,9 @@ export type ReplayRecord = {
   };
   dist: null | string;
   /**
-   * Difference of `updated-at` and `created-at` in seconds.
+   * Difference of `finishedAt` and `startedAt` in seconds.
    */
-  duration: number; // Seconds
+  duration: Duration;
   environment: null | string;
   errorIds: string[];
   /**