Webvitals for replays (#2627)

* adding new web vitals track

* adding new web vitals track

* update vitals message

* feat(heuristics): added web vitals support to the page event builder

* update mtype

* feat(heuristics): applied a new value type

* feat(heuristics): fixed if err case

* feat(heuristics): fixed the sql issue

* new event display

* tracker v 15.0.0 start

---------

Co-authored-by: Alexander <zavorotynskiy@pm.me>
This commit is contained in:
Delirium 2024-09-30 16:08:42 +02:00 committed by GitHub
parent 702bad06b9
commit 97a08853e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 563 additions and 111 deletions

View file

@ -60,7 +60,7 @@ func main() {
messages.MsgFetch, messages.MsgNetworkRequest, messages.MsgGraphQL, messages.MsgStateAction, messages.MsgMouseClick,
messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated,
messages.MsgPageLoadTiming, messages.MsgPageRenderTiming,
messages.MsgPageEvent, messages.MsgMouseThrashing, messages.MsgInputChange,
messages.MsgPageEvent, messages.MsgPageEventDeprecated, messages.MsgMouseThrashing, messages.MsgInputChange,
messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger,
// Mobile messages
messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID,

View file

@ -126,11 +126,11 @@ func (conn *BulkSet) initBulks() {
"events.pages",
"(session_id, message_id, timestamp, referrer, base_referrer, host, path, query, dom_content_loaded_time, "+
"load_time, response_end, first_paint_time, first_contentful_paint_time, speed_index, visually_complete, "+
"time_to_interactive, response_time, dom_building_time)",
"time_to_interactive, response_time, dom_building_time, web_vitals)",
"($%d, $%d, $%d, LEFT($%d, 8000), LEFT($%d, 8000), LEFT($%d, 300), LEFT($%d, 2000), LEFT($%d, 8000), "+
"NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0),"+
" NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0))",
18, 200)
" NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, 0), NULLIF($%d, ''))",
19, 200)
if err != nil {
conn.log.Fatal(conn.ctx, "can't create webPageEvents bulk: %s", err)
}

View file

@ -116,7 +116,8 @@ func (conn *Conn) InsertWebPageEvent(sess *sessions.Session, e *messages.PageEve
// base_path is deprecated
if err = conn.bulks.Get("webPageEvents").Append(sess.SessionID, truncSqIdx(e.MessageID), e.Timestamp, e.Referrer, url.DiscardURLQuery(e.Referrer),
host, path, query, e.DomContentLoadedEventEnd, e.LoadEventEnd, e.ResponseEnd, e.FirstPaint, e.FirstContentfulPaint,
e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive, calcResponseTime(e), calcDomBuildingTime(e)); err != nil {
e.SpeedIndex, e.VisuallyComplete, e.TimeToInteractive, calcResponseTime(e), calcDomBuildingTime(e),
e.WebVitals); err != nil {
sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID)
conn.log.Error(sessCtx, "insert web page event in bulk err: %s", err)
}

View file

@ -1,6 +1,8 @@
package custom
import (
"encoding/json"
"fmt"
. "openreplay/backend/pkg/messages"
)
@ -9,6 +11,7 @@ const PageEventTimeout = 1 * 60 * 1000
type pageEventBuilder struct {
pageEvent *PageEvent
firstTimingHandled bool
webVitals map[string]string
}
func NewPageEventBuilder() *pageEventBuilder {
@ -69,7 +72,7 @@ func (b *pageEventBuilder) Handle(message Message, timestamp uint64) Message {
if msg.FirstContentfulPaint <= 30000 {
b.pageEvent.FirstContentfulPaint = msg.FirstContentfulPaint
}
return b.buildIfTimingsComplete()
return nil //b.buildIfTimingsComplete()
case *PageRenderTiming:
if b.pageEvent == nil {
break
@ -77,8 +80,12 @@ func (b *pageEventBuilder) Handle(message Message, timestamp uint64) Message {
b.pageEvent.SpeedIndex = msg.SpeedIndex
b.pageEvent.VisuallyComplete = msg.VisuallyComplete
b.pageEvent.TimeToInteractive = msg.TimeToInteractive
return b.buildIfTimingsComplete()
return nil //b.buildIfTimingsComplete()
case *WebVitals:
if b.webVitals == nil {
b.webVitals = make(map[string]string)
}
b.webVitals[msg.Name] = msg.Value
}
if b.pageEvent != nil && b.pageEvent.Timestamp+PageEventTimeout < timestamp {
@ -94,13 +101,21 @@ func (b *pageEventBuilder) Build() Message {
pageEvent := b.pageEvent
b.pageEvent = nil
b.firstTimingHandled = false
if b.webVitals != nil {
if vitals, err := json.Marshal(b.webVitals); err == nil {
pageEvent.WebVitals = string(vitals)
} else {
// DEBUG
fmt.Printf("Error marshalling web vitals: %v\n", err)
}
}
return pageEvent
}
func (b *pageEventBuilder) buildIfTimingsComplete() Message {
if b.firstTimingHandled {
return b.Build()
}
b.firstTimingHandled = true
return nil
}
//func (b *pageEventBuilder) buildIfTimingsComplete() Message {
// if b.firstTimingHandled {
// return b.Build()
// }
// b.firstTimingHandled = true
// return nil
//}

View file

@ -2,7 +2,7 @@
package messages
func IsReplayerType(id int) bool {
return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 42 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 112 != id && 115 != id && 125 != id && 126 != id && 127 != id && 90 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 107 != id && 110 != id
return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 33 != id && 42 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 112 != id && 115 != id && 124 != id && 125 != id && 126 != id && 127 != id && 90 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 107 != id && 110 != id
}
func IsMobileType(id int) bool {

View file

@ -59,6 +59,27 @@ func transformDeprecated(msg Message) Message {
NavigationStart: m.NavigationStart,
DocumentTitle: "",
}
case *PageEventDeprecated:
return &PageEvent{
MessageID: m.MessageID,
Timestamp: m.Timestamp,
URL: m.URL,
Referrer: m.Referrer,
Loaded: m.Loaded,
RequestStart: m.RequestStart,
ResponseStart: m.ResponseStart,
ResponseEnd: m.ResponseEnd,
DomContentLoadedEventStart: m.DomContentLoadedEventStart,
DomContentLoadedEventEnd: m.DomContentLoadedEventEnd,
LoadEventStart: m.LoadEventStart,
LoadEventEnd: m.LoadEventEnd,
FirstPaint: m.FirstPaint,
FirstContentfulPaint: m.FirstContentfulPaint,
SpeedIndex: m.SpeedIndex,
VisuallyComplete: m.VisuallyComplete,
TimeToInteractive: m.TimeToInteractive,
WebVitals: "",
}
}
return msg
}

View file

@ -32,8 +32,9 @@ const (
MsgUserID = 28
MsgUserAnonymousID = 29
MsgMetadata = 30
MsgPageEvent = 31
MsgPageEventDeprecated = 31
MsgInputEvent = 32
MsgPageEvent = 33
MsgCSSInsertRule = 37
MsgCSSDeleteRule = 38
MsgFetch = 39
@ -91,6 +92,7 @@ const (
MsgRedux = 121
MsgSetPageLocation = 122
MsgGraphQL = 123
MsgWebVitals = 124
MsgIssueEvent = 125
MsgSessionEnd = 126
MsgSessionSearch = 127
@ -874,7 +876,7 @@ func (msg *Metadata) TypeID() int {
return 30
}
type PageEvent struct {
type PageEventDeprecated struct {
message
MessageID uint64
Timestamp uint64
@ -895,7 +897,7 @@ type PageEvent struct {
TimeToInteractive uint64
}
func (msg *PageEvent) Encode() []byte {
func (msg *PageEventDeprecated) Encode() []byte {
buf := make([]byte, 171+len(msg.URL)+len(msg.Referrer))
buf[0] = 31
p := 1
@ -919,11 +921,11 @@ func (msg *PageEvent) Encode() []byte {
return buf[:p]
}
func (msg *PageEvent) Decode() Message {
func (msg *PageEventDeprecated) Decode() Message {
return msg
}
func (msg *PageEvent) TypeID() int {
func (msg *PageEventDeprecated) TypeID() int {
return 31
}
@ -956,6 +958,61 @@ func (msg *InputEvent) TypeID() int {
return 32
}
type PageEvent struct {
message
MessageID uint64
Timestamp uint64
URL string
Referrer string
Loaded bool
RequestStart uint64
ResponseStart uint64
ResponseEnd uint64
DomContentLoadedEventStart uint64
DomContentLoadedEventEnd uint64
LoadEventStart uint64
LoadEventEnd uint64
FirstPaint uint64
FirstContentfulPaint uint64
SpeedIndex uint64
VisuallyComplete uint64
TimeToInteractive uint64
WebVitals string
}
func (msg *PageEvent) Encode() []byte {
buf := make([]byte, 181+len(msg.URL)+len(msg.Referrer)+len(msg.WebVitals))
buf[0] = 33
p := 1
p = WriteUint(msg.MessageID, buf, p)
p = WriteUint(msg.Timestamp, buf, p)
p = WriteString(msg.URL, buf, p)
p = WriteString(msg.Referrer, buf, p)
p = WriteBoolean(msg.Loaded, buf, p)
p = WriteUint(msg.RequestStart, buf, p)
p = WriteUint(msg.ResponseStart, buf, p)
p = WriteUint(msg.ResponseEnd, buf, p)
p = WriteUint(msg.DomContentLoadedEventStart, buf, p)
p = WriteUint(msg.DomContentLoadedEventEnd, buf, p)
p = WriteUint(msg.LoadEventStart, buf, p)
p = WriteUint(msg.LoadEventEnd, buf, p)
p = WriteUint(msg.FirstPaint, buf, p)
p = WriteUint(msg.FirstContentfulPaint, buf, p)
p = WriteUint(msg.SpeedIndex, buf, p)
p = WriteUint(msg.VisuallyComplete, buf, p)
p = WriteUint(msg.TimeToInteractive, buf, p)
p = WriteString(msg.WebVitals, buf, p)
return buf[:p]
}
func (msg *PageEvent) Decode() Message {
return msg
}
func (msg *PageEvent) TypeID() int {
return 33
}
type CSSInsertRule struct {
message
ID uint64
@ -2443,6 +2500,29 @@ func (msg *GraphQL) TypeID() int {
return 123
}
type WebVitals struct {
message
Name string
Value string
}
func (msg *WebVitals) Encode() []byte {
buf := make([]byte, 21+len(msg.Name)+len(msg.Value))
buf[0] = 124
p := 1
p = WriteString(msg.Name, buf, p)
p = WriteString(msg.Value, buf, p)
return buf[:p]
}
func (msg *WebVitals) Decode() Message {
return msg
}
func (msg *WebVitals) TypeID() int {
return 124
}
type IssueEvent struct {
message
MessageID uint64

View file

@ -468,9 +468,9 @@ func DecodeMetadata(reader BytesReader) (Message, error) {
return msg, err
}
func DecodePageEvent(reader BytesReader) (Message, error) {
func DecodePageEventDeprecated(reader BytesReader) (Message, error) {
var err error = nil
msg := &PageEvent{}
msg := &PageEventDeprecated{}
if msg.MessageID, err = reader.ReadUint(); err != nil {
return nil, err
}
@ -546,6 +546,66 @@ func DecodeInputEvent(reader BytesReader) (Message, error) {
return msg, err
}
func DecodePageEvent(reader BytesReader) (Message, error) {
var err error = nil
msg := &PageEvent{}
if msg.MessageID, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.Timestamp, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.URL, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Referrer, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Loaded, err = reader.ReadBoolean(); err != nil {
return nil, err
}
if msg.RequestStart, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.ResponseStart, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.ResponseEnd, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.DomContentLoadedEventStart, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.DomContentLoadedEventEnd, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.LoadEventStart, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.LoadEventEnd, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.FirstPaint, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.FirstContentfulPaint, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.SpeedIndex, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.VisuallyComplete, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.TimeToInteractive, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.WebVitals, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
}
func DecodeCSSInsertRule(reader BytesReader) (Message, error) {
var err error = nil
msg := &CSSInsertRule{}
@ -1494,6 +1554,18 @@ func DecodeGraphQL(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeWebVitals(reader BytesReader) (Message, error) {
var err error = nil
msg := &WebVitals{}
if msg.Name, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Value, err = reader.ReadString(); err != nil {
return nil, err
}
return msg, err
}
func DecodeIssueEvent(reader BytesReader) (Message, error) {
var err error = nil
msg := &IssueEvent{}
@ -2019,9 +2091,11 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
case 30:
return DecodeMetadata(reader)
case 31:
return DecodePageEvent(reader)
return DecodePageEventDeprecated(reader)
case 32:
return DecodeInputEvent(reader)
case 33:
return DecodePageEvent(reader)
case 37:
return DecodeCSSInsertRule(reader)
case 38:
@ -2136,6 +2210,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeSetPageLocation(reader)
case 123:
return DecodeGraphQL(reader)
case 124:
return DecodeWebVitals(reader)
case 125:
return DecodeIssueEvent(reader)
case 126:

View file

@ -281,7 +281,7 @@ class Metadata(Message):
self.value = value
class PageEvent(Message):
class PageEventDeprecated(Message):
__id__ = 31
def __init__(self, message_id, timestamp, url, referrer, loaded, request_start, response_start, response_end, dom_content_loaded_event_start, dom_content_loaded_event_end, load_event_start, load_event_end, first_paint, first_contentful_paint, speed_index, visually_complete, time_to_interactive):
@ -315,6 +315,30 @@ class InputEvent(Message):
self.label = label
class PageEvent(Message):
__id__ = 33
def __init__(self, message_id, timestamp, url, referrer, loaded, request_start, response_start, response_end, dom_content_loaded_event_start, dom_content_loaded_event_end, load_event_start, load_event_end, first_paint, first_contentful_paint, speed_index, visually_complete, time_to_interactive, web_vitals):
self.message_id = message_id
self.timestamp = timestamp
self.url = url
self.referrer = referrer
self.loaded = loaded
self.request_start = request_start
self.response_start = response_start
self.response_end = response_end
self.dom_content_loaded_event_start = dom_content_loaded_event_start
self.dom_content_loaded_event_end = dom_content_loaded_event_end
self.load_event_start = load_event_start
self.load_event_end = load_event_end
self.first_paint = first_paint
self.first_contentful_paint = first_contentful_paint
self.speed_index = speed_index
self.visually_complete = visually_complete
self.time_to_interactive = time_to_interactive
self.web_vitals = web_vitals
class CSSInsertRule(Message):
__id__ = 37
@ -859,6 +883,14 @@ class GraphQL(Message):
self.duration = duration
class WebVitals(Message):
__id__ = 124
def __init__(self, name, value):
self.name = name
self.value = value
class IssueEvent(Message):
__id__ = 125

View file

@ -410,7 +410,7 @@ cdef class Metadata(PyMessage):
self.value = value
cdef class PageEvent(PyMessage):
cdef class PageEventDeprecated(PyMessage):
cdef public int __id__
cdef public unsigned long message_id
cdef public unsigned long timestamp
@ -468,6 +468,49 @@ cdef class InputEvent(PyMessage):
self.label = label
cdef class PageEvent(PyMessage):
cdef public int __id__
cdef public unsigned long message_id
cdef public unsigned long timestamp
cdef public str url
cdef public str referrer
cdef public bint loaded
cdef public unsigned long request_start
cdef public unsigned long response_start
cdef public unsigned long response_end
cdef public unsigned long dom_content_loaded_event_start
cdef public unsigned long dom_content_loaded_event_end
cdef public unsigned long load_event_start
cdef public unsigned long load_event_end
cdef public unsigned long first_paint
cdef public unsigned long first_contentful_paint
cdef public unsigned long speed_index
cdef public unsigned long visually_complete
cdef public unsigned long time_to_interactive
cdef public str web_vitals
def __init__(self, unsigned long message_id, unsigned long timestamp, str url, str referrer, bint loaded, unsigned long request_start, unsigned long response_start, unsigned long response_end, unsigned long dom_content_loaded_event_start, unsigned long dom_content_loaded_event_end, unsigned long load_event_start, unsigned long load_event_end, unsigned long first_paint, unsigned long first_contentful_paint, unsigned long speed_index, unsigned long visually_complete, unsigned long time_to_interactive, str web_vitals):
self.__id__ = 33
self.message_id = message_id
self.timestamp = timestamp
self.url = url
self.referrer = referrer
self.loaded = loaded
self.request_start = request_start
self.response_start = response_start
self.response_end = response_end
self.dom_content_loaded_event_start = dom_content_loaded_event_start
self.dom_content_loaded_event_end = dom_content_loaded_event_end
self.load_event_start = load_event_start
self.load_event_end = load_event_end
self.first_paint = first_paint
self.first_contentful_paint = first_contentful_paint
self.speed_index = speed_index
self.visually_complete = visually_complete
self.time_to_interactive = time_to_interactive
self.web_vitals = web_vitals
cdef class CSSInsertRule(PyMessage):
cdef public int __id__
cdef public unsigned long id
@ -1271,6 +1314,17 @@ cdef class GraphQL(PyMessage):
self.duration = duration
cdef class WebVitals(PyMessage):
cdef public int __id__
cdef public str name
cdef public str value
def __init__(self, str name, str value):
self.__id__ = 124
self.name = name
self.value = value
cdef class IssueEvent(PyMessage):
cdef public int __id__
cdef public unsigned long message_id

View file

@ -309,7 +309,7 @@ class MessageCodec(Codec):
)
if message_id == 31:
return PageEvent(
return PageEventDeprecated(
message_id=self.read_uint(reader),
timestamp=self.read_uint(reader),
url=self.read_string(reader),
@ -338,6 +338,28 @@ class MessageCodec(Codec):
label=self.read_string(reader)
)
if message_id == 33:
return PageEvent(
message_id=self.read_uint(reader),
timestamp=self.read_uint(reader),
url=self.read_string(reader),
referrer=self.read_string(reader),
loaded=self.read_boolean(reader),
request_start=self.read_uint(reader),
response_start=self.read_uint(reader),
response_end=self.read_uint(reader),
dom_content_loaded_event_start=self.read_uint(reader),
dom_content_loaded_event_end=self.read_uint(reader),
load_event_start=self.read_uint(reader),
load_event_end=self.read_uint(reader),
first_paint=self.read_uint(reader),
first_contentful_paint=self.read_uint(reader),
speed_index=self.read_uint(reader),
visually_complete=self.read_uint(reader),
time_to_interactive=self.read_uint(reader),
web_vitals=self.read_string(reader)
)
if message_id == 37:
return CSSInsertRule(
id=self.read_uint(reader),
@ -768,6 +790,12 @@ class MessageCodec(Codec):
duration=self.read_uint(reader)
)
if message_id == 124:
return WebVitals(
name=self.read_string(reader),
value=self.read_string(reader)
)
if message_id == 125:
return IssueEvent(
message_id=self.read_uint(reader),

View file

@ -407,7 +407,7 @@ cdef class MessageCodec:
)
if message_id == 31:
return PageEvent(
return PageEventDeprecated(
message_id=self.read_uint(reader),
timestamp=self.read_uint(reader),
url=self.read_string(reader),
@ -436,6 +436,28 @@ cdef class MessageCodec:
label=self.read_string(reader)
)
if message_id == 33:
return PageEvent(
message_id=self.read_uint(reader),
timestamp=self.read_uint(reader),
url=self.read_string(reader),
referrer=self.read_string(reader),
loaded=self.read_boolean(reader),
request_start=self.read_uint(reader),
response_start=self.read_uint(reader),
response_end=self.read_uint(reader),
dom_content_loaded_event_start=self.read_uint(reader),
dom_content_loaded_event_end=self.read_uint(reader),
load_event_start=self.read_uint(reader),
load_event_end=self.read_uint(reader),
first_paint=self.read_uint(reader),
first_contentful_paint=self.read_uint(reader),
speed_index=self.read_uint(reader),
visually_complete=self.read_uint(reader),
time_to_interactive=self.read_uint(reader),
web_vitals=self.read_string(reader)
)
if message_id == 37:
return CSSInsertRule(
id=self.read_uint(reader),
@ -866,6 +888,12 @@ cdef class MessageCodec:
duration=self.read_uint(reader)
)
if message_id == 124:
return WebVitals(
name=self.read_string(reader),
value=self.read_string(reader)
)
if message_id == 125:
return IssueEvent(
message_id=self.read_uint(reader),

View file

@ -25,7 +25,6 @@ type Props = {
isCurrent?: boolean;
onClick?: () => void;
showSelection?: boolean;
showLoadInfo?: boolean;
toggleLoadInfo?: () => void;
isRed?: boolean;
presentInSearch?: boolean;
@ -52,7 +51,6 @@ const Event: React.FC<Props> = ({
isCurrent = false,
onClick,
showSelection = false,
showLoadInfo,
toggleLoadInfo,
isRed = false,
presentInSearch = false,
@ -251,25 +249,26 @@ const Event: React.FC<Props> = ({
{renderBody()}
</div>
{isLocation &&
(event.fcpTime ||
event.visuallyComplete ||
event.timeToInteractive) && (
<LoadInfo
showInfo={showLoadInfo}
onClick={toggleLoadInfo}
event={event}
prorata={prorata({
parts: 100,
elements: {
a: event.fcpTime,
b: event.visuallyComplete,
c: event.timeToInteractive,
},
startDivisorFn: (elements) => elements / 1.2,
divisorFn: (elements, parts) => elements / (2 * parts + 1),
})}
/>
)}
(event.fcpTime ||
event.visuallyComplete ||
event.timeToInteractive ||
event.webvitals) ? (
<LoadInfo
onClick={toggleLoadInfo}
event={event}
webvitals={event.webvitals}
prorata={prorata({
parts: 100,
elements: {
a: event.fcpTime,
b: event.visuallyComplete,
c: event.timeToInteractive,
},
startDivisorFn: (elements) => elements / 1.2,
divisorFn: (elements, parts) => elements / (2 * parts + 1),
})}
/>
) : null}
</div>
);
};

View file

@ -2,14 +2,11 @@ import UxtEvent from "Components/Session_/EventsBlock/UxtEvent";
import React from 'react';
import { connect } from 'react-redux';
import { TextEllipsis, Icon } from 'UI';
import withToggle from 'HOCs/withToggle';
import { TYPES } from 'Types/session/event';
import Event from './Event';
import stl from './eventGroupWrapper.module.css';
import NoteEvent from './NoteEvent';
// TODO: incapsulate toggler in LocationEvent
@withToggle('showLoadInfo', 'toggleLoadInfo')
@connect(
(state) => ({
members: state.getIn(['members', 'list']),
@ -17,14 +14,6 @@ import NoteEvent from './NoteEvent';
}),
)
class EventGroupWrapper extends React.Component {
toggleLoadInfo = (e) => {
e.stopPropagation();
this.props.toggleLoadInfo();
};
componentDidMount() {
this.props.toggleLoadInfo(this.props.isFirst);
}
onEventClick = (e) => this.props.onEventClick(e, this.props.event);
@ -39,7 +28,6 @@ class EventGroupWrapper extends React.Component {
isCurrent,
isEditing,
showSelection,
showLoadInfo,
isFirst,
presentInSearch,
isNote,
@ -77,8 +65,6 @@ class EventGroupWrapper extends React.Component {
event={event}
onClick={this.onEventClick}
selected={isSelected}
showLoadInfo={showLoadInfo}
toggleLoadInfo={this.toggleLoadInfo}
isCurrent={isCurrent}
presentInSearch={presentInSearch}
isLastInGroup={isLastInGroup}

View file

@ -1,49 +1,98 @@
import React from 'react';
import styles from './loadInfo.module.css';
import { numberWithCommas } from 'App/utils'
const LoadInfo = ({ showInfo = false, onClick, event: { fcpTime, visuallyComplete, timeToInteractive }, prorata: { a, b, c } }) => (
import { numberWithCommas } from 'App/utils';
import styles from './loadInfo.module.css';
const LoadInfo = ({
webvitals,
event: { fcpTime, visuallyComplete, timeToInteractive },
prorata: { a, b, c },
}) => (
<div>
<div className={ styles.bar } onClick={ onClick }>
{ typeof fcpTime === 'number' && <div style={ { width: `${ a }%` } } /> }
{ typeof visuallyComplete === 'number' && <div style={ { width: `${ b }%` } } /> }
{ typeof timeToInteractive === 'number' && <div style={ { width: `${ c }%` } } /> }
<div className={styles.bar}>
{typeof fcpTime === 'number' && <div style={{ width: `${a}%` }} />}
{typeof visuallyComplete === 'number' && (
<div style={{ width: `${b}%` }} />
)}
{typeof timeToInteractive === 'number' && (
<div style={{ width: `${c}%` }} />
)}
</div>
<div className={ styles.bottomBlock } data-hidden={ !showInfo }>
{ typeof fcpTime === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Time to Render' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(fcpTime || 0) }ms` }</div>
<div className={styles.bottomBlock}>
{typeof fcpTime === 'number' && (
<div className={styles.wrapper}>
<div className={styles.lines} />
<div className={styles.label}>{'Time to Render'}</div>
<div className={styles.value}>{`${numberWithCommas(
fcpTime || 0
)}ms`}</div>
</div>
}
{ typeof visuallyComplete === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Visually Complete' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(visuallyComplete || 0) }ms` }</div>
)}
{typeof visuallyComplete === 'number' && (
<div className={styles.wrapper}>
<div className={styles.lines} />
<div className={styles.label}>{'Visually Complete'}</div>
<div className={styles.value}>{`${numberWithCommas(
visuallyComplete || 0
)}ms`}</div>
</div>
}
{ typeof timeToInteractive === 'number' &&
<div className={ styles.wrapper }>
<div className={ styles.lines } />
<div className={ styles.label } >{ 'Time To Interactive' }</div>
<div className={ styles.value }>{ `${ numberWithCommas(timeToInteractive || 0) }ms` }</div>
)}
{typeof timeToInteractive === 'number' && (
<div className={styles.wrapper}>
<div className={styles.lines} />
<div className={styles.label}>{'Time To Interactive'}</div>
<div className={styles.value}>{`${numberWithCommas(
timeToInteractive || 0
)}ms`}</div>
</div>
}
{/* <div className={ styles.download }>
<a>
<Icon name="download" />
{ '.HAR' }
</a>
<div>
{ new Date().toString() }
</div>
</div> */}
)}
{webvitals
? Object.keys(webvitals).map((key) => (
<WebVitalsValueMemo name={key.toUpperCase()} value={webvitals[key]} />
))
: null}
</div>
</div>
);
function WebVitalsValue({ name, value }) {
const valInt = Number(value);
const valDisplay =
name !== 'CLS'
? Math.round(valInt)
: valInt > 1
? Math.round(valInt)
: valInt.toExponential(1).split('e');
const unit = {
CLS: 'score',
FCP: 'ms',
INP: 'ms',
LCP: 'ms',
TTFB: 'ms',
};
return (
<div className={styles.wrapper}>
<div className={styles.lines} />
<div className={styles.label}>{name}</div>
<div className={styles.value}>
{Array.isArray(valDisplay) ? (
<>
{valDisplay[0]}&times; 10<sup>{valDisplay[1]}</sup>
</>
) : (
<>
{valDisplay} {unit[name]}
</>
)}
</div>
</div>
);
}
const WebVitalsValueMemo = React.memo(WebVitalsValue);
LoadInfo.displayName = 'LoadInfo';
export default LoadInfo;
export default React.memo(LoadInfo);

View file

@ -550,8 +550,14 @@ type TrGraphQL = [
duration: number,
]
type TrWebVitals = [
type: 124,
name: string,
value: string,
]
export type TrackerMessage = TrTimestamp | TrSetPageLocationDeprecated | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrReduxDeprecated | TrVuex | TrMobX | TrNgRx | TrGraphQLDeprecated | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrMouseClickDeprecated | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrWSChannel | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode | TrTagTrigger | TrRedux | TrSetPageLocation | TrGraphQL
export type TrackerMessage = TrTimestamp | TrSetPageLocationDeprecated | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrReduxDeprecated | TrVuex | TrMobX | TrNgRx | TrGraphQLDeprecated | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrMouseClickDeprecated | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrWSChannel | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode | TrTagTrigger | TrRedux | TrSetPageLocation | TrGraphQL | TrWebVitals
export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) {

View file

@ -80,6 +80,7 @@ export interface LocationEvent extends IEvent {
referrer: string;
firstContentfulPaintTime: number;
firstPaintTime: number;
webVitals: string | null;
}
export type EventData = ConsoleEvent | ClickEvent | InputEvent | LocationEvent | IEvent;
@ -192,12 +193,19 @@ export class Location extends Event {
visuallyComplete: LocationEvent['visuallyComplete'];
timeToInteractive: LocationEvent['timeToInteractive'];
referrer: LocationEvent['referrer'];
webvitals: {
cls?: number;
lcp?: number;
inp?: number;
ttfb?: number;
} | null;
constructor(evt: LocationEvent) {
super(evt);
Object.assign(this, {
...evt,
fcpTime: evt.firstContentfulPaintTime || evt.firstPaintTime,
webvitals: evt.webVitals ? JSON.parse(evt.webVitals) : null,
});
}
}

View file

@ -261,7 +261,6 @@ export default class Session {
const isMobile = ['console', 'mobile', 'tablet'].includes(userDeviceType);
const events: InjectedEvent[] = [];
const rawEvents: (EventData & { key: number })[] = [];
if (session.events?.length) {
(session.events as EventData[]).forEach((event: EventData, k) => {
@ -271,7 +270,6 @@ export default class Session {
if (EventClass) {
events.push(EventClass);
}
rawEvents.push({ ...event, time, key: k });
}
});
}
@ -306,7 +304,7 @@ export default class Session {
const frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || [];
const mixedEventsWithIssues = mergeEventLists(
mergeEventLists(rawEvents, rawNotes),
mergeEventLists(events, rawNotes),
frustrationIssues
).sort(sortEvents)
@ -377,7 +375,6 @@ export default class Session {
const events: InjectedEvent[] = [];
const uxtDoneEvents = userTestingEvents.filter(e => e.status === 'done' && e.title).map(e => ({ ...e, type: 'UXT_EVENT', key: e.signal_id }))
const rawEvents: (EventData & { key: number })[] = [];
let uxtIndexNum = 0;
if (sessionEvents.length) {
@ -394,7 +391,6 @@ export default class Session {
if (EventClass) {
events.push(EventClass);
}
rawEvents.push({ ...event, time, key: k, });
}
});
}
@ -412,7 +408,7 @@ export default class Session {
const frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || [];
const mixedEventsWithIssues = mergeEventLists(
rawEvents,
events,
frustrationIssues.filter(i => i.type !== issueTypes.DEAD_CLICK)
)

View file

@ -163,7 +163,7 @@ message 30, 'Metadata', :replayer => false do
string 'Key'
string 'Value'
end
message 31, 'PageEvent', :tracker => false, :replayer => false do
message 31, 'PageEventDeprecated', :tracker => false, :replayer => false do
uint 'MessageID'
uint 'Timestamp'
string 'URL'
@ -182,6 +182,7 @@ message 31, 'PageEvent', :tracker => false, :replayer => false do
uint 'VisuallyComplete'
uint 'TimeToInteractive'
end
message 32, 'InputEvent', :tracker => false, :replayer => false do
uint 'MessageID'
uint 'Timestamp'
@ -190,6 +191,27 @@ message 32, 'InputEvent', :tracker => false, :replayer => false do
string 'Label'
end
message 33, 'PageEvent', :tracker => false, :replayer => false do
uint 'MessageID'
uint 'Timestamp'
string 'URL'
string 'Referrer'
boolean 'Loaded'
uint 'RequestStart'
uint 'ResponseStart'
uint 'ResponseEnd'
uint 'DomContentLoadedEventStart'
uint 'DomContentLoadedEventEnd'
uint 'LoadEventStart'
uint 'LoadEventEnd'
uint 'FirstPaint'
uint 'FirstContentfulPaint'
uint 'SpeedIndex'
uint 'VisuallyComplete'
uint 'TimeToInteractive'
string 'WebVitals'
end
# DEPRECATED since 4.0.2 in favor of AdoptedSSInsertRule + AdoptedSSAddOwner
message 37, 'CSSInsertRule' do
uint 'ID'
@ -556,6 +578,11 @@ message 123, 'GraphQL', :replayer => :devtools do
uint 'Duration'
end
message 124, 'WebVitals', :replayer => false do
string 'Name'
string 'Value'
end
## Backend-only
message 125, 'IssueEvent', :replayer => false, :tracker => false do
uint 'MessageID'

View file

@ -1,3 +1,7 @@
# 15.0.0
- new webvitals messages source
# 14.0.8
- use separate library to handle network requests ([@openreplay/network-proxy](https://www.npmjs.com/package/@openreplay/network-proxy))

Binary file not shown.

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "14.0.8",
"version": "15.0.0",
"keywords": [
"logging",
"replay"
@ -52,7 +52,8 @@
"@medv/finder": "^3.2.0",
"@openreplay/network-proxy": "^1.0.3",
"error-stack-parser": "^2.0.6",
"fflate": "^0.8.2"
"fflate": "^0.8.2",
"web-vitals": "^4.2.3"
},
"engines": {
"node": ">=14.0"

View file

@ -78,6 +78,7 @@ export declare const enum Type {
Redux = 121,
SetPageLocation = 122,
GraphQL = 123,
WebVitals = 124,
}
@ -626,6 +627,12 @@ export type GraphQL = [
/*duration:*/ number,
]
export type WebVitals = [
/*type:*/ Type.WebVitals,
/*name:*/ string,
/*value:*/ string,
]
type Message = Timestamp | SetPageLocationDeprecated | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | ReduxDeprecated | Vuex | MobX | NgRx | GraphQLDeprecated | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | MouseClickDeprecated | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger | Redux | SetPageLocation | GraphQL
type Message = Timestamp | SetPageLocationDeprecated | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | ReduxDeprecated | Vuex | MobX | NgRx | GraphQLDeprecated | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | MouseClickDeprecated | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger | Redux | SetPageLocation | GraphQL | WebVitals
export default Message

View file

@ -1019,3 +1019,14 @@ export function GraphQL(
]
}
export function WebVitals(
name: string,
value: string,
): Messages.WebVitals {
return [
Messages.Type.WebVitals,
name,
value,
]
}

View file

@ -1,7 +1,8 @@
import type App from '../app/index.js'
import { hasTag } from '../app/guards.js'
import { isURL, getTimeOrigin } from '../utils.js'
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../app/messages.gen.js'
import { ResourceTiming, PageLoadTiming, PageRenderTiming, WebVitals } from '../app/messages.gen.js'
import { onCLS, onINP, onLCP, onTTFB, Metric } from 'web-vitals'
// Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js
@ -139,6 +140,12 @@ export default function (app: App, opts: Partial<Options>): void {
const observer = new PerformanceObserver((list) => list.getEntries().forEach(resourceTiming))
function onVitalsSignal<T extends Metric>(msg: T) {
if (app.active()) {
return app.send(WebVitals(msg.name, String(msg.value)))
}
}
let prevSessionID: string | undefined
app.attachStartCallback(function ({ sessionID }) {
if (sessionID !== prevSessionID) {
@ -147,6 +154,18 @@ export default function (app: App, opts: Partial<Options>): void {
prevSessionID = sessionID
}
observer.observe({ entryTypes: ['resource'] })
// browser support:
// onCLS(): Chromium
// onFCP(): Chromium, Firefox, Safari
// onFID(): Chromium, Firefox (Deprecated)
// onINP(): Chromium
// onLCP(): Chromium, Firefox
// onTTFB(): Chromium, Firefox, Safari
onCLS(onVitalsSignal)
onINP(onVitalsSignal)
onLCP(onVitalsSignal)
onTTFB(onVitalsSignal)
})
app.attachStopCallback(function () {

View file

@ -314,6 +314,10 @@ export default class MessageEncoder extends PrimitiveEncoder {
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.uint(msg[5])
break
case Messages.Type.WebVitals:
return this.string(msg[1]) && this.string(msg[2])
break
}
}