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.MsgFetch, messages.MsgNetworkRequest, messages.MsgGraphQL, messages.MsgStateAction, messages.MsgMouseClick,
messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated, messages.MsgMouseClickDeprecated, messages.MsgSetPageLocation, messages.MsgSetPageLocationDeprecated,
messages.MsgPageLoadTiming, messages.MsgPageRenderTiming, messages.MsgPageLoadTiming, messages.MsgPageRenderTiming,
messages.MsgPageEvent, messages.MsgMouseThrashing, messages.MsgInputChange, messages.MsgPageEvent, messages.MsgPageEventDeprecated, messages.MsgMouseThrashing, messages.MsgInputChange,
messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger, messages.MsgUnbindNodes, messages.MsgCanvasNode, messages.MsgTagTrigger,
// Mobile messages // Mobile messages
messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID, messages.MsgMobileSessionStart, messages.MsgMobileSessionEnd, messages.MsgMobileUserID, messages.MsgMobileUserAnonymousID,

View file

@ -126,11 +126,11 @@ func (conn *BulkSet) initBulks() {
"events.pages", "events.pages",
"(session_id, message_id, timestamp, referrer, base_referrer, host, path, query, dom_content_loaded_time, "+ "(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, "+ "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), "+ "($%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), 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), NULLIF($%d, ''))",
18, 200) 19, 200)
if err != nil { if err != nil {
conn.log.Fatal(conn.ctx, "can't create webPageEvents bulk: %s", err) 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 // base_path is deprecated
if err = conn.bulks.Get("webPageEvents").Append(sess.SessionID, truncSqIdx(e.MessageID), e.Timestamp, e.Referrer, url.DiscardURLQuery(e.Referrer), 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, 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) sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID)
conn.log.Error(sessCtx, "insert web page event in bulk err: %s", err) conn.log.Error(sessCtx, "insert web page event in bulk err: %s", err)
} }

View file

@ -1,6 +1,8 @@
package custom package custom
import ( import (
"encoding/json"
"fmt"
. "openreplay/backend/pkg/messages" . "openreplay/backend/pkg/messages"
) )
@ -9,6 +11,7 @@ const PageEventTimeout = 1 * 60 * 1000
type pageEventBuilder struct { type pageEventBuilder struct {
pageEvent *PageEvent pageEvent *PageEvent
firstTimingHandled bool firstTimingHandled bool
webVitals map[string]string
} }
func NewPageEventBuilder() *pageEventBuilder { func NewPageEventBuilder() *pageEventBuilder {
@ -69,7 +72,7 @@ func (b *pageEventBuilder) Handle(message Message, timestamp uint64) Message {
if msg.FirstContentfulPaint <= 30000 { if msg.FirstContentfulPaint <= 30000 {
b.pageEvent.FirstContentfulPaint = msg.FirstContentfulPaint b.pageEvent.FirstContentfulPaint = msg.FirstContentfulPaint
} }
return b.buildIfTimingsComplete() return nil //b.buildIfTimingsComplete()
case *PageRenderTiming: case *PageRenderTiming:
if b.pageEvent == nil { if b.pageEvent == nil {
break break
@ -77,8 +80,12 @@ func (b *pageEventBuilder) Handle(message Message, timestamp uint64) Message {
b.pageEvent.SpeedIndex = msg.SpeedIndex b.pageEvent.SpeedIndex = msg.SpeedIndex
b.pageEvent.VisuallyComplete = msg.VisuallyComplete b.pageEvent.VisuallyComplete = msg.VisuallyComplete
b.pageEvent.TimeToInteractive = msg.TimeToInteractive 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 { if b.pageEvent != nil && b.pageEvent.Timestamp+PageEventTimeout < timestamp {
@ -94,13 +101,21 @@ func (b *pageEventBuilder) Build() Message {
pageEvent := b.pageEvent pageEvent := b.pageEvent
b.pageEvent = nil b.pageEvent = nil
b.firstTimingHandled = false 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 return pageEvent
} }
func (b *pageEventBuilder) buildIfTimingsComplete() Message { //func (b *pageEventBuilder) buildIfTimingsComplete() Message {
if b.firstTimingHandled { // if b.firstTimingHandled {
return b.Build() // return b.Build()
} // }
b.firstTimingHandled = true // b.firstTimingHandled = true
return nil // return nil
} //}

View file

@ -2,7 +2,7 @@
package messages package messages
func IsReplayerType(id int) bool { 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 { func IsMobileType(id int) bool {

View file

@ -59,6 +59,27 @@ func transformDeprecated(msg Message) Message {
NavigationStart: m.NavigationStart, NavigationStart: m.NavigationStart,
DocumentTitle: "", 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 return msg
} }

View file

@ -32,8 +32,9 @@ const (
MsgUserID = 28 MsgUserID = 28
MsgUserAnonymousID = 29 MsgUserAnonymousID = 29
MsgMetadata = 30 MsgMetadata = 30
MsgPageEvent = 31 MsgPageEventDeprecated = 31
MsgInputEvent = 32 MsgInputEvent = 32
MsgPageEvent = 33
MsgCSSInsertRule = 37 MsgCSSInsertRule = 37
MsgCSSDeleteRule = 38 MsgCSSDeleteRule = 38
MsgFetch = 39 MsgFetch = 39
@ -91,6 +92,7 @@ const (
MsgRedux = 121 MsgRedux = 121
MsgSetPageLocation = 122 MsgSetPageLocation = 122
MsgGraphQL = 123 MsgGraphQL = 123
MsgWebVitals = 124
MsgIssueEvent = 125 MsgIssueEvent = 125
MsgSessionEnd = 126 MsgSessionEnd = 126
MsgSessionSearch = 127 MsgSessionSearch = 127
@ -874,7 +876,7 @@ func (msg *Metadata) TypeID() int {
return 30 return 30
} }
type PageEvent struct { type PageEventDeprecated struct {
message message
MessageID uint64 MessageID uint64
Timestamp uint64 Timestamp uint64
@ -895,7 +897,7 @@ type PageEvent struct {
TimeToInteractive uint64 TimeToInteractive uint64
} }
func (msg *PageEvent) Encode() []byte { func (msg *PageEventDeprecated) Encode() []byte {
buf := make([]byte, 171+len(msg.URL)+len(msg.Referrer)) buf := make([]byte, 171+len(msg.URL)+len(msg.Referrer))
buf[0] = 31 buf[0] = 31
p := 1 p := 1
@ -919,11 +921,11 @@ func (msg *PageEvent) Encode() []byte {
return buf[:p] return buf[:p]
} }
func (msg *PageEvent) Decode() Message { func (msg *PageEventDeprecated) Decode() Message {
return msg return msg
} }
func (msg *PageEvent) TypeID() int { func (msg *PageEventDeprecated) TypeID() int {
return 31 return 31
} }
@ -956,6 +958,61 @@ func (msg *InputEvent) TypeID() int {
return 32 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 { type CSSInsertRule struct {
message message
ID uint64 ID uint64
@ -2443,6 +2500,29 @@ func (msg *GraphQL) TypeID() int {
return 123 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 { type IssueEvent struct {
message message
MessageID uint64 MessageID uint64

View file

@ -468,9 +468,9 @@ func DecodeMetadata(reader BytesReader) (Message, error) {
return msg, err return msg, err
} }
func DecodePageEvent(reader BytesReader) (Message, error) { func DecodePageEventDeprecated(reader BytesReader) (Message, error) {
var err error = nil var err error = nil
msg := &PageEvent{} msg := &PageEventDeprecated{}
if msg.MessageID, err = reader.ReadUint(); err != nil { if msg.MessageID, err = reader.ReadUint(); err != nil {
return nil, err return nil, err
} }
@ -546,6 +546,66 @@ func DecodeInputEvent(reader BytesReader) (Message, error) {
return msg, err 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) { func DecodeCSSInsertRule(reader BytesReader) (Message, error) {
var err error = nil var err error = nil
msg := &CSSInsertRule{} msg := &CSSInsertRule{}
@ -1494,6 +1554,18 @@ func DecodeGraphQL(reader BytesReader) (Message, error) {
return msg, err 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) { func DecodeIssueEvent(reader BytesReader) (Message, error) {
var err error = nil var err error = nil
msg := &IssueEvent{} msg := &IssueEvent{}
@ -2019,9 +2091,11 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
case 30: case 30:
return DecodeMetadata(reader) return DecodeMetadata(reader)
case 31: case 31:
return DecodePageEvent(reader) return DecodePageEventDeprecated(reader)
case 32: case 32:
return DecodeInputEvent(reader) return DecodeInputEvent(reader)
case 33:
return DecodePageEvent(reader)
case 37: case 37:
return DecodeCSSInsertRule(reader) return DecodeCSSInsertRule(reader)
case 38: case 38:
@ -2136,6 +2210,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeSetPageLocation(reader) return DecodeSetPageLocation(reader)
case 123: case 123:
return DecodeGraphQL(reader) return DecodeGraphQL(reader)
case 124:
return DecodeWebVitals(reader)
case 125: case 125:
return DecodeIssueEvent(reader) return DecodeIssueEvent(reader)
case 126: case 126:

View file

@ -281,7 +281,7 @@ class Metadata(Message):
self.value = value self.value = value
class PageEvent(Message): class PageEventDeprecated(Message):
__id__ = 31 __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): 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 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): class CSSInsertRule(Message):
__id__ = 37 __id__ = 37
@ -859,6 +883,14 @@ class GraphQL(Message):
self.duration = duration self.duration = duration
class WebVitals(Message):
__id__ = 124
def __init__(self, name, value):
self.name = name
self.value = value
class IssueEvent(Message): class IssueEvent(Message):
__id__ = 125 __id__ = 125

View file

@ -410,7 +410,7 @@ cdef class Metadata(PyMessage):
self.value = value self.value = value
cdef class PageEvent(PyMessage): cdef class PageEventDeprecated(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long message_id cdef public unsigned long message_id
cdef public unsigned long timestamp cdef public unsigned long timestamp
@ -468,6 +468,49 @@ cdef class InputEvent(PyMessage):
self.label = label 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 class CSSInsertRule(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long id cdef public unsigned long id
@ -1271,6 +1314,17 @@ cdef class GraphQL(PyMessage):
self.duration = duration 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 class IssueEvent(PyMessage):
cdef public int __id__ cdef public int __id__
cdef public unsigned long message_id cdef public unsigned long message_id

View file

@ -309,7 +309,7 @@ class MessageCodec(Codec):
) )
if message_id == 31: if message_id == 31:
return PageEvent( return PageEventDeprecated(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),
timestamp=self.read_uint(reader), timestamp=self.read_uint(reader),
url=self.read_string(reader), url=self.read_string(reader),
@ -338,6 +338,28 @@ class MessageCodec(Codec):
label=self.read_string(reader) 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: if message_id == 37:
return CSSInsertRule( return CSSInsertRule(
id=self.read_uint(reader), id=self.read_uint(reader),
@ -768,6 +790,12 @@ class MessageCodec(Codec):
duration=self.read_uint(reader) 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: if message_id == 125:
return IssueEvent( return IssueEvent(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),

View file

@ -407,7 +407,7 @@ cdef class MessageCodec:
) )
if message_id == 31: if message_id == 31:
return PageEvent( return PageEventDeprecated(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),
timestamp=self.read_uint(reader), timestamp=self.read_uint(reader),
url=self.read_string(reader), url=self.read_string(reader),
@ -436,6 +436,28 @@ cdef class MessageCodec:
label=self.read_string(reader) 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: if message_id == 37:
return CSSInsertRule( return CSSInsertRule(
id=self.read_uint(reader), id=self.read_uint(reader),
@ -866,6 +888,12 @@ cdef class MessageCodec:
duration=self.read_uint(reader) 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: if message_id == 125:
return IssueEvent( return IssueEvent(
message_id=self.read_uint(reader), message_id=self.read_uint(reader),

View file

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

View file

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

View file

@ -1,49 +1,98 @@
import React from 'react'; 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>
<div className={ styles.bar } onClick={ onClick }> <div className={styles.bar}>
{ typeof fcpTime === 'number' && <div style={ { width: `${ a }%` } } /> } {typeof fcpTime === 'number' && <div style={{ width: `${a}%` }} />}
{ typeof visuallyComplete === 'number' && <div style={ { width: `${ b }%` } } /> } {typeof visuallyComplete === 'number' && (
{ typeof timeToInteractive === 'number' && <div style={ { width: `${ c }%` } } /> } <div style={{ width: `${b}%` }} />
)}
{typeof timeToInteractive === 'number' && (
<div style={{ width: `${c}%` }} />
)}
</div> </div>
<div className={ styles.bottomBlock } data-hidden={ !showInfo }> <div className={styles.bottomBlock}>
{ typeof fcpTime === 'number' && {typeof fcpTime === 'number' && (
<div className={ styles.wrapper }> <div className={styles.wrapper}>
<div className={ styles.lines } /> <div className={styles.lines} />
<div className={ styles.label } >{ 'Time to Render' }</div> <div className={styles.label}>{'Time to Render'}</div>
<div className={ styles.value }>{ `${ numberWithCommas(fcpTime || 0) }ms` }</div> <div className={styles.value}>{`${numberWithCommas(
fcpTime || 0
)}ms`}</div>
</div> </div>
} )}
{ typeof visuallyComplete === 'number' && {typeof visuallyComplete === 'number' && (
<div className={ styles.wrapper }> <div className={styles.wrapper}>
<div className={ styles.lines } /> <div className={styles.lines} />
<div className={ styles.label } >{ 'Visually Complete' }</div> <div className={styles.label}>{'Visually Complete'}</div>
<div className={ styles.value }>{ `${ numberWithCommas(visuallyComplete || 0) }ms` }</div> <div className={styles.value}>{`${numberWithCommas(
visuallyComplete || 0
)}ms`}</div>
</div> </div>
} )}
{ typeof timeToInteractive === 'number' && {typeof timeToInteractive === 'number' && (
<div className={ styles.wrapper }> <div className={styles.wrapper}>
<div className={ styles.lines } /> <div className={styles.lines} />
<div className={ styles.label } >{ 'Time To Interactive' }</div> <div className={styles.label}>{'Time To Interactive'}</div>
<div className={ styles.value }>{ `${ numberWithCommas(timeToInteractive || 0) }ms` }</div> <div className={styles.value}>{`${numberWithCommas(
timeToInteractive || 0
)}ms`}</div>
</div> </div>
} )}
{/* <div className={ styles.download }> {webvitals
<a> ? Object.keys(webvitals).map((key) => (
<Icon name="download" /> <WebVitalsValueMemo name={key.toUpperCase()} value={webvitals[key]} />
{ '.HAR' } ))
</a> : null}
<div>
{ new Date().toString() }
</div>
</div> */}
</div> </div>
</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'; LoadInfo.displayName = 'LoadInfo';
export default LoadInfo; export default React.memo(LoadInfo);

View file

@ -550,8 +550,14 @@ type TrGraphQL = [
duration: number, 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 { export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) { switch(tMsg[0]) {

View file

@ -80,6 +80,7 @@ export interface LocationEvent extends IEvent {
referrer: string; referrer: string;
firstContentfulPaintTime: number; firstContentfulPaintTime: number;
firstPaintTime: number; firstPaintTime: number;
webVitals: string | null;
} }
export type EventData = ConsoleEvent | ClickEvent | InputEvent | LocationEvent | IEvent; export type EventData = ConsoleEvent | ClickEvent | InputEvent | LocationEvent | IEvent;
@ -192,12 +193,19 @@ export class Location extends Event {
visuallyComplete: LocationEvent['visuallyComplete']; visuallyComplete: LocationEvent['visuallyComplete'];
timeToInteractive: LocationEvent['timeToInteractive']; timeToInteractive: LocationEvent['timeToInteractive'];
referrer: LocationEvent['referrer']; referrer: LocationEvent['referrer'];
webvitals: {
cls?: number;
lcp?: number;
inp?: number;
ttfb?: number;
} | null;
constructor(evt: LocationEvent) { constructor(evt: LocationEvent) {
super(evt); super(evt);
Object.assign(this, { Object.assign(this, {
...evt, ...evt,
fcpTime: evt.firstContentfulPaintTime || evt.firstPaintTime, 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 isMobile = ['console', 'mobile', 'tablet'].includes(userDeviceType);
const events: InjectedEvent[] = []; const events: InjectedEvent[] = [];
const rawEvents: (EventData & { key: number })[] = [];
if (session.events?.length) { if (session.events?.length) {
(session.events as EventData[]).forEach((event: EventData, k) => { (session.events as EventData[]).forEach((event: EventData, k) => {
@ -271,7 +270,6 @@ export default class Session {
if (EventClass) { if (EventClass) {
events.push(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 frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || [];
const mixedEventsWithIssues = mergeEventLists( const mixedEventsWithIssues = mergeEventLists(
mergeEventLists(rawEvents, rawNotes), mergeEventLists(events, rawNotes),
frustrationIssues frustrationIssues
).sort(sortEvents) ).sort(sortEvents)
@ -377,7 +375,6 @@ export default class Session {
const events: InjectedEvent[] = []; const events: InjectedEvent[] = [];
const uxtDoneEvents = userTestingEvents.filter(e => e.status === 'done' && e.title).map(e => ({ ...e, type: 'UXT_EVENT', key: e.signal_id })) 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; let uxtIndexNum = 0;
if (sessionEvents.length) { if (sessionEvents.length) {
@ -394,7 +391,6 @@ export default class Session {
if (EventClass) { if (EventClass) {
events.push(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 frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || [];
const mixedEventsWithIssues = mergeEventLists( const mixedEventsWithIssues = mergeEventLists(
rawEvents, events,
frustrationIssues.filter(i => i.type !== issueTypes.DEAD_CLICK) frustrationIssues.filter(i => i.type !== issueTypes.DEAD_CLICK)
) )

View file

@ -163,7 +163,7 @@ message 30, 'Metadata', :replayer => false do
string 'Key' string 'Key'
string 'Value' string 'Value'
end end
message 31, 'PageEvent', :tracker => false, :replayer => false do message 31, 'PageEventDeprecated', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'URL' string 'URL'
@ -182,6 +182,7 @@ message 31, 'PageEvent', :tracker => false, :replayer => false do
uint 'VisuallyComplete' uint 'VisuallyComplete'
uint 'TimeToInteractive' uint 'TimeToInteractive'
end end
message 32, 'InputEvent', :tracker => false, :replayer => false do message 32, 'InputEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
@ -190,6 +191,27 @@ message 32, 'InputEvent', :tracker => false, :replayer => false do
string 'Label' string 'Label'
end 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 # DEPRECATED since 4.0.2 in favor of AdoptedSSInsertRule + AdoptedSSAddOwner
message 37, 'CSSInsertRule' do message 37, 'CSSInsertRule' do
uint 'ID' uint 'ID'
@ -556,6 +578,11 @@ message 123, 'GraphQL', :replayer => :devtools do
uint 'Duration' uint 'Duration'
end end
message 124, 'WebVitals', :replayer => false do
string 'Name'
string 'Value'
end
## Backend-only ## Backend-only
message 125, 'IssueEvent', :replayer => false, :tracker => false do message 125, 'IssueEvent', :replayer => false, :tracker => false do
uint 'MessageID' uint 'MessageID'

View file

@ -1,3 +1,7 @@
# 15.0.0
- new webvitals messages source
# 14.0.8 # 14.0.8
- use separate library to handle network requests ([@openreplay/network-proxy](https://www.npmjs.com/package/@openreplay/network-proxy)) - 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", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "14.0.8", "version": "15.0.0",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"
@ -52,7 +52,8 @@
"@medv/finder": "^3.2.0", "@medv/finder": "^3.2.0",
"@openreplay/network-proxy": "^1.0.3", "@openreplay/network-proxy": "^1.0.3",
"error-stack-parser": "^2.0.6", "error-stack-parser": "^2.0.6",
"fflate": "^0.8.2" "fflate": "^0.8.2",
"web-vitals": "^4.2.3"
}, },
"engines": { "engines": {
"node": ">=14.0" "node": ">=14.0"

View file

@ -78,6 +78,7 @@ export declare const enum Type {
Redux = 121, Redux = 121,
SetPageLocation = 122, SetPageLocation = 122,
GraphQL = 123, GraphQL = 123,
WebVitals = 124,
} }
@ -626,6 +627,12 @@ export type GraphQL = [
/*duration:*/ number, /*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 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 type App from '../app/index.js'
import { hasTag } from '../app/guards.js' import { hasTag } from '../app/guards.js'
import { isURL, getTimeOrigin } from '../utils.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 // 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)) 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 let prevSessionID: string | undefined
app.attachStartCallback(function ({ sessionID }) { app.attachStartCallback(function ({ sessionID }) {
if (sessionID !== prevSessionID) { if (sessionID !== prevSessionID) {
@ -147,6 +154,18 @@ export default function (app: App, opts: Partial<Options>): void {
prevSessionID = sessionID prevSessionID = sessionID
} }
observer.observe({ entryTypes: ['resource'] }) 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 () { 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]) return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.uint(msg[5])
break break
case Messages.Type.WebVitals:
return this.string(msg[1]) && this.string(msg[2])
break
} }
} }