diff --git a/backend/pkg/db/postgres/bulks.go b/backend/pkg/db/postgres/bulks.go index 44ca4b4d2..cd73fc6a9 100644 --- a/backend/pkg/db/postgres/bulks.go +++ b/backend/pkg/db/postgres/bulks.go @@ -12,7 +12,7 @@ type bulksTask struct { } func NewBulksTask() *bulksTask { - return &bulksTask{bulks: make([]Bulk, 0, 15)} + return &bulksTask{bulks: make([]Bulk, 0, 16)} } type BulkSet struct { @@ -32,6 +32,7 @@ type BulkSet struct { webIssueEvents Bulk webCustomEvents Bulk webClickEvents Bulk + webClickXYEvents Bulk webNetworkRequest Bulk webCanvasNodes Bulk webTagTriggers Bulk @@ -82,6 +83,8 @@ func (conn *BulkSet) Get(name string) Bulk { return conn.webCustomEvents case "webClickEvents": return conn.webClickEvents + case "webClickXYEvents": + return conn.webClickXYEvents case "webNetworkRequest": return conn.webNetworkRequest case "canvasNodes": @@ -203,6 +206,14 @@ func (conn *BulkSet) initBulks() { if err != nil { conn.log.Fatal(conn.ctx, "can't create webClickEvents bulk: %s", err) } + conn.webClickXYEvents, err = NewBulk(conn.c, + "events.clicks", + "(session_id, message_id, timestamp, label, selector, url, path, hesitation, normalized_x, normalized_y)", + "($%d, $%d, $%d, NULLIF(LEFT($%d, 2000), ''), LEFT($%d, 8000), LEFT($%d, 2000), LEFT($%d, 2000), $%d, $%d, $%d)", + 10, 200) + if err != nil { + conn.log.Fatal(conn.ctx, "can't create webClickEvents bulk: %s", err) + } conn.webNetworkRequest, err = NewBulk(conn.c, "events_common.requests", "(session_id, timestamp, seq_index, url, host, path, query, request_body, response_body, status_code, method, duration, success, transfer_size)", @@ -246,6 +257,7 @@ func (conn *BulkSet) Send() { newTask.bulks = append(newTask.bulks, conn.webIssueEvents) newTask.bulks = append(newTask.bulks, conn.webCustomEvents) newTask.bulks = append(newTask.bulks, conn.webClickEvents) + newTask.bulks = append(newTask.bulks, conn.webClickXYEvents) newTask.bulks = append(newTask.bulks, conn.webNetworkRequest) newTask.bulks = append(newTask.bulks, conn.webCanvasNodes) newTask.bulks = append(newTask.bulks, conn.webTagTriggers) diff --git a/backend/pkg/db/postgres/events.go b/backend/pkg/db/postgres/events.go index 68213cae3..3e1bff5e2 100644 --- a/backend/pkg/db/postgres/events.go +++ b/backend/pkg/db/postgres/events.go @@ -132,9 +132,16 @@ func (conn *Conn) InsertWebClickEvent(sess *sessions.Session, e *messages.MouseC } var host, path string host, path, _, _ = url.GetURLParts(e.Url) - if err := conn.bulks.Get("webClickEvents").Append(sess.SessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime); err != nil { - sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID) - conn.log.Error(sessCtx, "insert web click event in bulk err: %s", err) + if e.NormalizedX <= 100 && e.NormalizedY <= 100 { + if err := conn.bulks.Get("webClickXYEvents").Append(sess.SessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime, e.NormalizedX, e.NormalizedY); err != nil { + sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID) + conn.log.Error(sessCtx, "insert web click event in bulk err: %s", err) + } + } else { + if err := conn.bulks.Get("webClickEvents").Append(sess.SessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime); err != nil { + sessCtx := context.WithValue(context.Background(), "sessionID", sess.SessionID) + conn.log.Error(sessCtx, "insert web click event in bulk err: %s", err) + } } // Add new value set to autocomplete bulk conn.InsertAutocompleteValue(sess.SessionID, sess.ProjectID, "CLICK", e.Label) diff --git a/backend/pkg/messages/filters.go b/backend/pkg/messages/filters.go index c4a41b57e..eea77cf9d 100644 --- a/backend/pkg/messages/filters.go +++ b/backend/pkg/messages/filters.go @@ -10,5 +10,5 @@ func IsMobileType(id int) bool { } func IsDOMType(id int) bool { - return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 119 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id + return 0 == id || 4 == id || 5 == id || 6 == id || 7 == id || 8 == id || 9 == id || 10 == id || 11 == id || 12 == id || 13 == id || 14 == id || 15 == id || 16 == id || 18 == id || 19 == id || 20 == id || 37 == id || 38 == id || 49 == id || 50 == id || 51 == id || 54 == id || 55 == id || 57 == id || 58 == id || 59 == id || 60 == id || 61 == id || 67 == id || 68 == id || 69 == id || 70 == id || 71 == id || 72 == id || 73 == id || 74 == id || 75 == id || 76 == id || 77 == id || 113 == id || 114 == id || 117 == id || 118 == id || 119 == id || 122 == id || 93 == id || 96 == id || 100 == id || 101 == id || 102 == id || 103 == id || 104 == id || 105 == id || 106 == id || 111 == id } diff --git a/backend/pkg/messages/legacy-message-transform.go b/backend/pkg/messages/legacy-message-transform.go index a267d1f26..b77a7d339 100644 --- a/backend/pkg/messages/legacy-message-transform.go +++ b/backend/pkg/messages/legacy-message-transform.go @@ -43,6 +43,15 @@ func transformDeprecated(msg Message) Message { TransferredSize: 0, Cached: false, } + case *MouseClickDeprecated: + return &MouseClick{ + ID: m.ID, + HesitationTime: m.HesitationTime, + Label: m.Label, + Selector: m.Selector, + NormalizedX: 101, // 101 is a magic number to signal that the value is not present + NormalizedY: 101, // 101 is a magic number to signal that the value is not present + } } return msg } diff --git a/backend/pkg/messages/messages.go b/backend/pkg/messages/messages.go index 4c0dfffa8..9f82d47cb 100644 --- a/backend/pkg/messages/messages.go +++ b/backend/pkg/messages/messages.go @@ -5,7 +5,7 @@ const ( MsgTimestamp = 0 MsgSessionStart = 1 MsgSessionEndDeprecated = 3 - MsgSetPageLocation = 4 + MsgSetPageLocationDeprecated = 4 MsgSetViewportSize = 5 MsgSetViewportScroll = 6 MsgCreateDocument = 7 @@ -62,7 +62,8 @@ const ( MsgCustomIssue = 64 MsgAssetCache = 66 MsgCSSInsertRuleURLBased = 67 - MsgMouseClick = 69 + MsgMouseClick = 68 + MsgMouseClickDeprecated = 69 MsgCreateIFrameDocument = 70 MsgAdoptedSSReplaceURLBased = 71 MsgAdoptedSSReplace = 72 @@ -88,6 +89,7 @@ const ( MsgCanvasNode = 119 MsgTagTrigger = 120 MsgRedux = 121 + MsgSetPageLocation = 122 MsgIssueEvent = 125 MsgSessionEnd = 126 MsgSessionSearch = 127 @@ -205,14 +207,14 @@ func (msg *SessionEndDeprecated) TypeID() int { return 3 } -type SetPageLocation struct { +type SetPageLocationDeprecated struct { message URL string Referrer string NavigationStart uint64 } -func (msg *SetPageLocation) Encode() []byte { +func (msg *SetPageLocationDeprecated) Encode() []byte { buf := make([]byte, 31+len(msg.URL)+len(msg.Referrer)) buf[0] = 4 p := 1 @@ -222,11 +224,11 @@ func (msg *SetPageLocation) Encode() []byte { return buf[:p] } -func (msg *SetPageLocation) Decode() Message { +func (msg *SetPageLocationDeprecated) Decode() Message { return msg } -func (msg *SetPageLocation) TypeID() int { +func (msg *SetPageLocationDeprecated) TypeID() int { return 4 } @@ -1693,9 +1695,40 @@ type MouseClick struct { HesitationTime uint64 Label string Selector string + NormalizedX uint64 + NormalizedY uint64 } func (msg *MouseClick) Encode() []byte { + buf := make([]byte, 61+len(msg.Label)+len(msg.Selector)) + buf[0] = 68 + p := 1 + p = WriteUint(msg.ID, buf, p) + p = WriteUint(msg.HesitationTime, buf, p) + p = WriteString(msg.Label, buf, p) + p = WriteString(msg.Selector, buf, p) + p = WriteUint(msg.NormalizedX, buf, p) + p = WriteUint(msg.NormalizedY, buf, p) + return buf[:p] +} + +func (msg *MouseClick) Decode() Message { + return msg +} + +func (msg *MouseClick) TypeID() int { + return 68 +} + +type MouseClickDeprecated struct { + message + ID uint64 + HesitationTime uint64 + Label string + Selector string +} + +func (msg *MouseClickDeprecated) Encode() []byte { buf := make([]byte, 41+len(msg.Label)+len(msg.Selector)) buf[0] = 69 p := 1 @@ -1706,11 +1739,11 @@ func (msg *MouseClick) Encode() []byte { return buf[:p] } -func (msg *MouseClick) Decode() Message { +func (msg *MouseClickDeprecated) Decode() Message { return msg } -func (msg *MouseClick) TypeID() int { +func (msg *MouseClickDeprecated) TypeID() int { return 69 } @@ -2351,6 +2384,33 @@ func (msg *Redux) TypeID() int { return 121 } +type SetPageLocation struct { + message + URL string + Referrer string + NavigationStart uint64 + DocumentTitle string +} + +func (msg *SetPageLocation) Encode() []byte { + buf := make([]byte, 41+len(msg.URL)+len(msg.Referrer)+len(msg.DocumentTitle)) + buf[0] = 122 + p := 1 + p = WriteString(msg.URL, buf, p) + p = WriteString(msg.Referrer, buf, p) + p = WriteUint(msg.NavigationStart, buf, p) + p = WriteString(msg.DocumentTitle, buf, p) + return buf[:p] +} + +func (msg *SetPageLocation) Decode() Message { + return msg +} + +func (msg *SetPageLocation) TypeID() int { + return 122 +} + type IssueEvent struct { message MessageID uint64 diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 5a9f59f6c..4ef9ba53f 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -77,9 +77,9 @@ func DecodeSessionEndDeprecated(reader BytesReader) (Message, error) { return msg, err } -func DecodeSetPageLocation(reader BytesReader) (Message, error) { +func DecodeSetPageLocationDeprecated(reader BytesReader) (Message, error) { var err error = nil - msg := &SetPageLocation{} + msg := &SetPageLocationDeprecated{} if msg.URL, err = reader.ReadString(); err != nil { return nil, err } @@ -1032,6 +1032,30 @@ func DecodeMouseClick(reader BytesReader) (Message, error) { if msg.Selector, err = reader.ReadString(); err != nil { return nil, err } + if msg.NormalizedX, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.NormalizedY, err = reader.ReadUint(); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMouseClickDeprecated(reader BytesReader) (Message, error) { + var err error = nil + msg := &MouseClickDeprecated{} + if msg.ID, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.HesitationTime, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.Label, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.Selector, err = reader.ReadString(); err != nil { + return nil, err + } return msg, err } @@ -1428,6 +1452,24 @@ func DecodeRedux(reader BytesReader) (Message, error) { return msg, err } +func DecodeSetPageLocation(reader BytesReader) (Message, error) { + var err error = nil + msg := &SetPageLocation{} + if msg.URL, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.Referrer, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.NavigationStart, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.DocumentTitle, err = reader.ReadString(); err != nil { + return nil, err + } + return msg, err +} + func DecodeIssueEvent(reader BytesReader) (Message, error) { var err error = nil msg := &IssueEvent{} @@ -1899,7 +1941,7 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { case 3: return DecodeSessionEndDeprecated(reader) case 4: - return DecodeSetPageLocation(reader) + return DecodeSetPageLocationDeprecated(reader) case 5: return DecodeSetViewportSize(reader) case 6: @@ -2012,8 +2054,10 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { return DecodeAssetCache(reader) case 67: return DecodeCSSInsertRuleURLBased(reader) - case 69: + case 68: return DecodeMouseClick(reader) + case 69: + return DecodeMouseClickDeprecated(reader) case 70: return DecodeCreateIFrameDocument(reader) case 71: @@ -2064,6 +2108,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { return DecodeTagTrigger(reader) case 121: return DecodeRedux(reader) + case 122: + return DecodeSetPageLocation(reader) case 125: return DecodeIssueEvent(reader) case 126: diff --git a/ee/backend/pkg/db/clickhouse/connector.go b/ee/backend/pkg/db/clickhouse/connector.go index 2fc4152e3..db6ec8171 100644 --- a/ee/backend/pkg/db/clickhouse/connector.go +++ b/ee/backend/pkg/db/clickhouse/connector.go @@ -119,7 +119,7 @@ var batches = map[string]string{ "resources": "INSERT INTO experimental.resources (session_id, project_id, message_id, datetime, url, type, duration, ttfb, header_size, encoded_body_size, decoded_body_size, success, url_path) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, SUBSTR(?, 1, 8000))", "autocompletes": "INSERT INTO experimental.autocomplete (project_id, type, value) VALUES (?, ?, SUBSTR(?, 1, 8000))", "pages": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, 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_time, speed_index, visually_complete, time_to_interactive, url_path, event_type) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, SUBSTR(?, 1, 8000), ?)", - "clicks": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, hesitation_time, event_type, selector) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "clicks": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, hesitation_time, event_type, selector, normalized_x, normalized_y) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "inputs": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, event_type, duration, hesitation_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "errors": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, source, name, message, error_id, event_type, error_tags_keys, error_tags_values) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "performance": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, min_fps, avg_fps, max_fps, min_cpu, avg_cpu, max_cpu, min_total_js_heap_size, avg_total_js_heap_size, max_total_js_heap_size, min_used_js_heap_size, avg_used_js_heap_size, max_used_js_heap_size, event_type) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -398,6 +398,14 @@ func (c *connectorImpl) InsertWebClickEvent(session *sessions.Session, msg *mess if msg.Label == "" { return nil } + var nX *uint32 = nil + var nY *uint32 = nil + if msg.NormalizedX <= 100 && msg.NormalizedY <= 100 { + nXVal := uint32(msg.NormalizedX) + nX = &nXVal + nYVal := uint32(msg.NormalizedY) + nY = &nYVal + } if err := c.batches["clicks"].Append( session.SessionID, uint16(session.ProjectID), @@ -407,6 +415,8 @@ func (c *connectorImpl) InsertWebClickEvent(session *sessions.Session, msg *mess nullableUint32(uint32(msg.HesitationTime)), "CLICK", msg.Selector, + nX, + nY, ); err != nil { c.checkError("clicks", err) return fmt.Errorf("can't append to clicks batch: %s", err) diff --git a/ee/connectors/msgcodec/messages.py b/ee/connectors/msgcodec/messages.py index d01f6bd14..5839934b0 100644 --- a/ee/connectors/msgcodec/messages.py +++ b/ee/connectors/msgcodec/messages.py @@ -42,7 +42,7 @@ class SessionEndDeprecated(Message): self.timestamp = timestamp -class SetPageLocation(Message): +class SetPageLocationDeprecated(Message): __id__ = 4 def __init__(self, url, referrer, navigation_start): @@ -585,6 +585,18 @@ class CSSInsertRuleURLBased(Message): class MouseClick(Message): + __id__ = 68 + + def __init__(self, id, hesitation_time, label, selector, normalized_x, normalized_y): + self.id = id + self.hesitation_time = hesitation_time + self.label = label + self.selector = selector + self.normalized_x = normalized_x + self.normalized_y = normalized_y + + +class MouseClickDeprecated(Message): __id__ = 69 def __init__(self, id, hesitation_time, label, selector): @@ -825,6 +837,16 @@ class Redux(Message): self.action_time = action_time +class SetPageLocation(Message): + __id__ = 122 + + def __init__(self, url, referrer, navigation_start, document_title): + self.url = url + self.referrer = referrer + self.navigation_start = navigation_start + self.document_title = document_title + + class IssueEvent(Message): __id__ = 125 @@ -854,7 +876,7 @@ class SessionSearch(Message): self.partition = partition -class IOSSessionStart(Message): +class MobileSessionStart(Message): __id__ = 90 def __init__(self, timestamp, project_id, tracker_version, rev_id, user_uuid, user_os, user_os_version, user_device, user_device_type, user_country): @@ -870,14 +892,14 @@ class IOSSessionStart(Message): self.user_country = user_country -class IOSSessionEnd(Message): +class MobileSessionEnd(Message): __id__ = 91 def __init__(self, timestamp): self.timestamp = timestamp -class IOSMetadata(Message): +class MobileMetadata(Message): __id__ = 92 def __init__(self, timestamp, length, key, value): @@ -887,7 +909,7 @@ class IOSMetadata(Message): self.value = value -class IOSEvent(Message): +class MobileEvent(Message): __id__ = 93 def __init__(self, timestamp, length, name, payload): @@ -897,7 +919,7 @@ class IOSEvent(Message): self.payload = payload -class IOSUserID(Message): +class MobileUserID(Message): __id__ = 94 def __init__(self, timestamp, length, id): @@ -906,7 +928,7 @@ class IOSUserID(Message): self.id = id -class IOSUserAnonymousID(Message): +class MobileUserAnonymousID(Message): __id__ = 95 def __init__(self, timestamp, length, id): @@ -915,7 +937,7 @@ class IOSUserAnonymousID(Message): self.id = id -class IOSScreenChanges(Message): +class MobileScreenChanges(Message): __id__ = 96 def __init__(self, timestamp, length, x, y, width, height): @@ -927,7 +949,7 @@ class IOSScreenChanges(Message): self.height = height -class IOSCrash(Message): +class MobileCrash(Message): __id__ = 97 def __init__(self, timestamp, length, name, reason, stacktrace): @@ -938,7 +960,7 @@ class IOSCrash(Message): self.stacktrace = stacktrace -class IOSViewComponentEvent(Message): +class MobileViewComponentEvent(Message): __id__ = 98 def __init__(self, timestamp, length, screen_name, view_name, visible): @@ -949,7 +971,7 @@ class IOSViewComponentEvent(Message): self.visible = visible -class IOSClickEvent(Message): +class MobileClickEvent(Message): __id__ = 100 def __init__(self, timestamp, length, label, x, y): @@ -960,7 +982,7 @@ class IOSClickEvent(Message): self.y = y -class IOSInputEvent(Message): +class MobileInputEvent(Message): __id__ = 101 def __init__(self, timestamp, length, value, value_masked, label): @@ -971,7 +993,7 @@ class IOSInputEvent(Message): self.label = label -class IOSPerformanceEvent(Message): +class MobilePerformanceEvent(Message): __id__ = 102 def __init__(self, timestamp, length, name, value): @@ -981,7 +1003,7 @@ class IOSPerformanceEvent(Message): self.value = value -class IOSLog(Message): +class MobileLog(Message): __id__ = 103 def __init__(self, timestamp, length, severity, content): @@ -991,7 +1013,7 @@ class IOSLog(Message): self.content = content -class IOSInternalError(Message): +class MobileInternalError(Message): __id__ = 104 def __init__(self, timestamp, length, content): @@ -1000,7 +1022,7 @@ class IOSInternalError(Message): self.content = content -class IOSNetworkCall(Message): +class MobileNetworkCall(Message): __id__ = 105 def __init__(self, timestamp, length, type, method, url, request, response, status, duration): @@ -1015,7 +1037,7 @@ class IOSNetworkCall(Message): self.duration = duration -class IOSSwipeEvent(Message): +class MobileSwipeEvent(Message): __id__ = 106 def __init__(self, timestamp, length, label, x, y, direction): @@ -1027,7 +1049,7 @@ class IOSSwipeEvent(Message): self.direction = direction -class IOSBatchMeta(Message): +class MobileBatchMeta(Message): __id__ = 107 def __init__(self, timestamp, length, first_index): @@ -1036,7 +1058,7 @@ class IOSBatchMeta(Message): self.first_index = first_index -class IOSPerformanceAggregated(Message): +class MobilePerformanceAggregated(Message): __id__ = 110 def __init__(self, timestamp_start, timestamp_end, min_fps, avg_fps, max_fps, min_cpu, avg_cpu, max_cpu, min_memory, avg_memory, max_memory, min_battery, avg_battery, max_battery): @@ -1056,7 +1078,7 @@ class IOSPerformanceAggregated(Message): self.max_battery = max_battery -class IOSIssueEvent(Message): +class MobileIssueEvent(Message): __id__ = 111 def __init__(self, timestamp, type, context_string, context, payload): diff --git a/ee/connectors/msgcodec/messages.pyx b/ee/connectors/msgcodec/messages.pyx index 9af6b6793..bbbb4257f 100644 --- a/ee/connectors/msgcodec/messages.pyx +++ b/ee/connectors/msgcodec/messages.pyx @@ -67,7 +67,7 @@ cdef class SessionEndDeprecated(PyMessage): self.timestamp = timestamp -cdef class SetPageLocation(PyMessage): +cdef class SetPageLocationDeprecated(PyMessage): cdef public int __id__ cdef public str url cdef public str referrer @@ -872,6 +872,25 @@ cdef class MouseClick(PyMessage): cdef public unsigned long hesitation_time cdef public str label cdef public str selector + cdef public unsigned long normalized_x + cdef public unsigned long normalized_y + + def __init__(self, unsigned long id, unsigned long hesitation_time, str label, str selector, unsigned long normalized_x, unsigned long normalized_y): + self.__id__ = 68 + self.id = id + self.hesitation_time = hesitation_time + self.label = label + self.selector = selector + self.normalized_x = normalized_x + self.normalized_y = normalized_y + + +cdef class MouseClickDeprecated(PyMessage): + cdef public int __id__ + cdef public unsigned long id + cdef public unsigned long hesitation_time + cdef public str label + cdef public str selector def __init__(self, unsigned long id, unsigned long hesitation_time, str label, str selector): self.__id__ = 69 @@ -1218,6 +1237,21 @@ cdef class Redux(PyMessage): self.action_time = action_time +cdef class SetPageLocation(PyMessage): + cdef public int __id__ + cdef public str url + cdef public str referrer + cdef public unsigned long navigation_start + cdef public str document_title + + def __init__(self, str url, str referrer, unsigned long navigation_start, str document_title): + self.__id__ = 122 + self.url = url + self.referrer = referrer + self.navigation_start = navigation_start + self.document_title = document_title + + cdef class IssueEvent(PyMessage): cdef public int __id__ cdef public unsigned long message_id @@ -1261,7 +1295,7 @@ cdef class SessionSearch(PyMessage): self.partition = partition -cdef class IOSSessionStart(PyMessage): +cdef class MobileSessionStart(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long project_id @@ -1288,7 +1322,7 @@ cdef class IOSSessionStart(PyMessage): self.user_country = user_country -cdef class IOSSessionEnd(PyMessage): +cdef class MobileSessionEnd(PyMessage): cdef public int __id__ cdef public unsigned long timestamp @@ -1297,7 +1331,7 @@ cdef class IOSSessionEnd(PyMessage): self.timestamp = timestamp -cdef class IOSMetadata(PyMessage): +cdef class MobileMetadata(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1312,7 +1346,7 @@ cdef class IOSMetadata(PyMessage): self.value = value -cdef class IOSEvent(PyMessage): +cdef class MobileEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1327,7 +1361,7 @@ cdef class IOSEvent(PyMessage): self.payload = payload -cdef class IOSUserID(PyMessage): +cdef class MobileUserID(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1340,7 +1374,7 @@ cdef class IOSUserID(PyMessage): self.id = id -cdef class IOSUserAnonymousID(PyMessage): +cdef class MobileUserAnonymousID(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1353,7 +1387,7 @@ cdef class IOSUserAnonymousID(PyMessage): self.id = id -cdef class IOSScreenChanges(PyMessage): +cdef class MobileScreenChanges(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1372,7 +1406,7 @@ cdef class IOSScreenChanges(PyMessage): self.height = height -cdef class IOSCrash(PyMessage): +cdef class MobileCrash(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1389,7 +1423,7 @@ cdef class IOSCrash(PyMessage): self.stacktrace = stacktrace -cdef class IOSViewComponentEvent(PyMessage): +cdef class MobileViewComponentEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1406,7 +1440,7 @@ cdef class IOSViewComponentEvent(PyMessage): self.visible = visible -cdef class IOSClickEvent(PyMessage): +cdef class MobileClickEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1423,7 +1457,7 @@ cdef class IOSClickEvent(PyMessage): self.y = y -cdef class IOSInputEvent(PyMessage): +cdef class MobileInputEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1440,7 +1474,7 @@ cdef class IOSInputEvent(PyMessage): self.label = label -cdef class IOSPerformanceEvent(PyMessage): +cdef class MobilePerformanceEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1455,7 +1489,7 @@ cdef class IOSPerformanceEvent(PyMessage): self.value = value -cdef class IOSLog(PyMessage): +cdef class MobileLog(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1470,7 +1504,7 @@ cdef class IOSLog(PyMessage): self.content = content -cdef class IOSInternalError(PyMessage): +cdef class MobileInternalError(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1483,7 +1517,7 @@ cdef class IOSInternalError(PyMessage): self.content = content -cdef class IOSNetworkCall(PyMessage): +cdef class MobileNetworkCall(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1508,7 +1542,7 @@ cdef class IOSNetworkCall(PyMessage): self.duration = duration -cdef class IOSSwipeEvent(PyMessage): +cdef class MobileSwipeEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1527,7 +1561,7 @@ cdef class IOSSwipeEvent(PyMessage): self.direction = direction -cdef class IOSBatchMeta(PyMessage): +cdef class MobileBatchMeta(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public unsigned long length @@ -1540,7 +1574,7 @@ cdef class IOSBatchMeta(PyMessage): self.first_index = first_index -cdef class IOSPerformanceAggregated(PyMessage): +cdef class MobilePerformanceAggregated(PyMessage): cdef public int __id__ cdef public unsigned long timestamp_start cdef public unsigned long timestamp_end @@ -1575,7 +1609,7 @@ cdef class IOSPerformanceAggregated(PyMessage): self.max_battery = max_battery -cdef class IOSIssueEvent(PyMessage): +cdef class MobileIssueEvent(PyMessage): cdef public int __id__ cdef public unsigned long timestamp cdef public str type diff --git a/ee/connectors/msgcodec/msgcodec.py b/ee/connectors/msgcodec/msgcodec.py index 009f3dcbd..c4b88569b 100644 --- a/ee/connectors/msgcodec/msgcodec.py +++ b/ee/connectors/msgcodec/msgcodec.py @@ -124,7 +124,7 @@ class MessageCodec(Codec): ) if message_id == 4: - return SetPageLocation( + return SetPageLocationDeprecated( url=self.read_string(reader), referrer=self.read_string(reader), navigation_start=self.read_uint(reader) @@ -551,8 +551,18 @@ class MessageCodec(Codec): base_url=self.read_string(reader) ) - if message_id == 69: + if message_id == 68: return MouseClick( + id=self.read_uint(reader), + hesitation_time=self.read_uint(reader), + label=self.read_string(reader), + selector=self.read_string(reader), + normalized_x=self.read_uint(reader), + normalized_y=self.read_uint(reader) + ) + + if message_id == 69: + return MouseClickDeprecated( id=self.read_uint(reader), hesitation_time=self.read_uint(reader), label=self.read_string(reader), @@ -740,6 +750,14 @@ class MessageCodec(Codec): action_time=self.read_uint(reader) ) + if message_id == 122: + return SetPageLocation( + url=self.read_string(reader), + referrer=self.read_string(reader), + navigation_start=self.read_uint(reader), + document_title=self.read_string(reader) + ) + if message_id == 125: return IssueEvent( message_id=self.read_uint(reader), @@ -764,7 +782,7 @@ class MessageCodec(Codec): ) if message_id == 90: - return IOSSessionStart( + return MobileSessionStart( timestamp=self.read_uint(reader), project_id=self.read_uint(reader), tracker_version=self.read_string(reader), @@ -778,12 +796,12 @@ class MessageCodec(Codec): ) if message_id == 91: - return IOSSessionEnd( + return MobileSessionEnd( timestamp=self.read_uint(reader) ) if message_id == 92: - return IOSMetadata( + return MobileMetadata( timestamp=self.read_uint(reader), length=self.read_uint(reader), key=self.read_string(reader), @@ -791,7 +809,7 @@ class MessageCodec(Codec): ) if message_id == 93: - return IOSEvent( + return MobileEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -799,21 +817,21 @@ class MessageCodec(Codec): ) if message_id == 94: - return IOSUserID( + return MobileUserID( timestamp=self.read_uint(reader), length=self.read_uint(reader), id=self.read_string(reader) ) if message_id == 95: - return IOSUserAnonymousID( + return MobileUserAnonymousID( timestamp=self.read_uint(reader), length=self.read_uint(reader), id=self.read_string(reader) ) if message_id == 96: - return IOSScreenChanges( + return MobileScreenChanges( timestamp=self.read_uint(reader), length=self.read_uint(reader), x=self.read_uint(reader), @@ -823,7 +841,7 @@ class MessageCodec(Codec): ) if message_id == 97: - return IOSCrash( + return MobileCrash( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -832,7 +850,7 @@ class MessageCodec(Codec): ) if message_id == 98: - return IOSViewComponentEvent( + return MobileViewComponentEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), screen_name=self.read_string(reader), @@ -841,7 +859,7 @@ class MessageCodec(Codec): ) if message_id == 100: - return IOSClickEvent( + return MobileClickEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), label=self.read_string(reader), @@ -850,7 +868,7 @@ class MessageCodec(Codec): ) if message_id == 101: - return IOSInputEvent( + return MobileInputEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), value=self.read_string(reader), @@ -859,7 +877,7 @@ class MessageCodec(Codec): ) if message_id == 102: - return IOSPerformanceEvent( + return MobilePerformanceEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -867,7 +885,7 @@ class MessageCodec(Codec): ) if message_id == 103: - return IOSLog( + return MobileLog( timestamp=self.read_uint(reader), length=self.read_uint(reader), severity=self.read_string(reader), @@ -875,14 +893,14 @@ class MessageCodec(Codec): ) if message_id == 104: - return IOSInternalError( + return MobileInternalError( timestamp=self.read_uint(reader), length=self.read_uint(reader), content=self.read_string(reader) ) if message_id == 105: - return IOSNetworkCall( + return MobileNetworkCall( timestamp=self.read_uint(reader), length=self.read_uint(reader), type=self.read_string(reader), @@ -895,7 +913,7 @@ class MessageCodec(Codec): ) if message_id == 106: - return IOSSwipeEvent( + return MobileSwipeEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), label=self.read_string(reader), @@ -905,14 +923,14 @@ class MessageCodec(Codec): ) if message_id == 107: - return IOSBatchMeta( + return MobileBatchMeta( timestamp=self.read_uint(reader), length=self.read_uint(reader), first_index=self.read_uint(reader) ) if message_id == 110: - return IOSPerformanceAggregated( + return MobilePerformanceAggregated( timestamp_start=self.read_uint(reader), timestamp_end=self.read_uint(reader), min_fps=self.read_uint(reader), @@ -930,7 +948,7 @@ class MessageCodec(Codec): ) if message_id == 111: - return IOSIssueEvent( + return MobileIssueEvent( timestamp=self.read_uint(reader), type=self.read_string(reader), context_string=self.read_string(reader), diff --git a/ee/connectors/msgcodec/msgcodec.pyx b/ee/connectors/msgcodec/msgcodec.pyx index b820758b9..791f7fae9 100644 --- a/ee/connectors/msgcodec/msgcodec.pyx +++ b/ee/connectors/msgcodec/msgcodec.pyx @@ -222,7 +222,7 @@ cdef class MessageCodec: ) if message_id == 4: - return SetPageLocation( + return SetPageLocationDeprecated( url=self.read_string(reader), referrer=self.read_string(reader), navigation_start=self.read_uint(reader) @@ -649,8 +649,18 @@ cdef class MessageCodec: base_url=self.read_string(reader) ) - if message_id == 69: + if message_id == 68: return MouseClick( + id=self.read_uint(reader), + hesitation_time=self.read_uint(reader), + label=self.read_string(reader), + selector=self.read_string(reader), + normalized_x=self.read_uint(reader), + normalized_y=self.read_uint(reader) + ) + + if message_id == 69: + return MouseClickDeprecated( id=self.read_uint(reader), hesitation_time=self.read_uint(reader), label=self.read_string(reader), @@ -838,6 +848,14 @@ cdef class MessageCodec: action_time=self.read_uint(reader) ) + if message_id == 122: + return SetPageLocation( + url=self.read_string(reader), + referrer=self.read_string(reader), + navigation_start=self.read_uint(reader), + document_title=self.read_string(reader) + ) + if message_id == 125: return IssueEvent( message_id=self.read_uint(reader), @@ -862,7 +880,7 @@ cdef class MessageCodec: ) if message_id == 90: - return IOSSessionStart( + return MobileSessionStart( timestamp=self.read_uint(reader), project_id=self.read_uint(reader), tracker_version=self.read_string(reader), @@ -876,12 +894,12 @@ cdef class MessageCodec: ) if message_id == 91: - return IOSSessionEnd( + return MobileSessionEnd( timestamp=self.read_uint(reader) ) if message_id == 92: - return IOSMetadata( + return MobileMetadata( timestamp=self.read_uint(reader), length=self.read_uint(reader), key=self.read_string(reader), @@ -889,7 +907,7 @@ cdef class MessageCodec: ) if message_id == 93: - return IOSEvent( + return MobileEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -897,21 +915,21 @@ cdef class MessageCodec: ) if message_id == 94: - return IOSUserID( + return MobileUserID( timestamp=self.read_uint(reader), length=self.read_uint(reader), id=self.read_string(reader) ) if message_id == 95: - return IOSUserAnonymousID( + return MobileUserAnonymousID( timestamp=self.read_uint(reader), length=self.read_uint(reader), id=self.read_string(reader) ) if message_id == 96: - return IOSScreenChanges( + return MobileScreenChanges( timestamp=self.read_uint(reader), length=self.read_uint(reader), x=self.read_uint(reader), @@ -921,7 +939,7 @@ cdef class MessageCodec: ) if message_id == 97: - return IOSCrash( + return MobileCrash( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -930,7 +948,7 @@ cdef class MessageCodec: ) if message_id == 98: - return IOSViewComponentEvent( + return MobileViewComponentEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), screen_name=self.read_string(reader), @@ -939,7 +957,7 @@ cdef class MessageCodec: ) if message_id == 100: - return IOSClickEvent( + return MobileClickEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), label=self.read_string(reader), @@ -948,7 +966,7 @@ cdef class MessageCodec: ) if message_id == 101: - return IOSInputEvent( + return MobileInputEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), value=self.read_string(reader), @@ -957,7 +975,7 @@ cdef class MessageCodec: ) if message_id == 102: - return IOSPerformanceEvent( + return MobilePerformanceEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), name=self.read_string(reader), @@ -965,7 +983,7 @@ cdef class MessageCodec: ) if message_id == 103: - return IOSLog( + return MobileLog( timestamp=self.read_uint(reader), length=self.read_uint(reader), severity=self.read_string(reader), @@ -973,14 +991,14 @@ cdef class MessageCodec: ) if message_id == 104: - return IOSInternalError( + return MobileInternalError( timestamp=self.read_uint(reader), length=self.read_uint(reader), content=self.read_string(reader) ) if message_id == 105: - return IOSNetworkCall( + return MobileNetworkCall( timestamp=self.read_uint(reader), length=self.read_uint(reader), type=self.read_string(reader), @@ -993,7 +1011,7 @@ cdef class MessageCodec: ) if message_id == 106: - return IOSSwipeEvent( + return MobileSwipeEvent( timestamp=self.read_uint(reader), length=self.read_uint(reader), label=self.read_string(reader), @@ -1003,14 +1021,14 @@ cdef class MessageCodec: ) if message_id == 107: - return IOSBatchMeta( + return MobileBatchMeta( timestamp=self.read_uint(reader), length=self.read_uint(reader), first_index=self.read_uint(reader) ) if message_id == 110: - return IOSPerformanceAggregated( + return MobilePerformanceAggregated( timestamp_start=self.read_uint(reader), timestamp_end=self.read_uint(reader), min_fps=self.read_uint(reader), @@ -1028,7 +1046,7 @@ cdef class MessageCodec: ) if message_id == 111: - return IOSIssueEvent( + return MobileIssueEvent( timestamp=self.read_uint(reader), type=self.read_string(reader), context_string=self.read_string(reader), diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx index f12510fac..cb2f5fbea 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx @@ -3,19 +3,15 @@ import { useStore } from 'App/mstore' import { observer } from 'mobx-react-lite' import ClickMapRenderer from 'App/components/Session/Player/ClickMapRenderer' import { connect } from 'react-redux' -import { setCustomSession, clearCurrentSession } from 'App/duck/sessions' -import { fetchInsights } from 'Duck/sessions'; +import { fetchInsights } from 'App/duck/sessions' import { NoContent, Icon } from 'App/components/ui' function ClickMapCard({ - setCustomSession, - visitedEvents, insights, fetchInsights, insightsFilters, - host, - clearCurrentSession, }: any) { + const [customSession, setCustomSession] = React.useState(null) const { metricStore, dashboardStore } = useStore(); const onMarkerClick = (s: string, innerText: string) => { metricStore.changeClickMapSearch(s, innerText) @@ -23,22 +19,24 @@ function ClickMapCard({ const mapUrl = metricStore.instance.series[0].filter.filters[0].value[0] React.useEffect(() => { - return () => clearCurrentSession() + return () => setCustomSession(null) }, []) + React.useEffect(() => { if (metricStore.instance.data.domURL) { - setCustomSession(metricStore.instance.data) + setCustomSession(null) + setTimeout(() => { + setCustomSession(metricStore.instance.data) + }, 100) } }, [metricStore.instance]) React.useEffect(() => { - if (visitedEvents.length) { - const rangeValue = dashboardStore.drillDownPeriod.rangeValue - const startDate = dashboardStore.drillDownPeriod.start - const endDate = dashboardStore.drillDownPeriod.end - fetchInsights({ ...insightsFilters, url: mapUrl || '/', startDate, endDate, rangeValue, clickRage: metricStore.clickMapFilter }) - } - }, [visitedEvents, metricStore.clickMapFilter]) + const rangeValue = dashboardStore.drillDownPeriod.rangeValue + const startDate = dashboardStore.drillDownPeriod.start + const endDate = dashboardStore.drillDownPeriod.end + fetchInsights({ ...insightsFilters, url: mapUrl || '/', startDate, endDate, rangeValue, clickRage: metricStore.clickMapFilter }) + }, [dashboardStore.drillDownPeriod.start, dashboardStore.drillDownPeriod.end, dashboardStore.drillDownPeriod.rangeValue, metricStore.clickMapFilter]) if (!metricStore.instance.data.domURL || insights.size === 0) { return ( @@ -57,7 +55,8 @@ function ClickMapCard({ /> ) } - if (!visitedEvents || !visitedEvents.length) { + + if (!metricStore.instance.data || !customSession) { return
Loading session
} @@ -71,7 +70,7 @@ function ClickMapCard({ return (
@@ -86,6 +85,6 @@ export default connect( insights: state.getIn(['sessions', 'insights']), host: state.getIn(['sessions', 'host']), }), - { setCustomSession, fetchInsights, clearCurrentSession } + { fetchInsights, } ) (observer(ClickMapCard)) diff --git a/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx b/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx index 51f1224c0..ab116d9ac 100644 --- a/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx +++ b/frontend/app/components/Session/Player/ClickMapRenderer/ThinPlayer.tsx @@ -11,28 +11,42 @@ import { toast } from 'react-toastify' function WebPlayer(props: any) { const { session, - customSession, insights, jumpTimestamp, - onMarkerClick, } = props; // @ts-ignore const [contextValue, setContextValue] = useState(defaultContextValue); + const playerRef = React.useRef(null); useEffect(() => { - const [WebPlayerInst, PlayerStore] = createClickMapPlayer( - customSession, - (state) => makeAutoObservable(state), - toast, - ); - setContextValue({ player: WebPlayerInst, store: PlayerStore }); + const init = () => { + const [WebPlayerInst, PlayerStore] = createClickMapPlayer( + session, + (state) => makeAutoObservable(state), + toast, + ); + playerRef.current = WebPlayerInst; + setContextValue({ player: WebPlayerInst, store: PlayerStore }); + } + if (!playerRef.current) { + init() + } else { + playerRef.current.clean() + playerRef.current = null; + setContextValue(defaultContextValue); + init(); + } + }, [session.sessionId]); + + React.useEffect(() => { return () => { - WebPlayerInst.clean(); + playerRef.current && playerRef.current.clean(); + playerRef.current = null; // @ts-ignore setContextValue(defaultContextValue); } - }, [session.sessionId]); + }, []) const isPlayerReady = contextValue.store?.get().ready @@ -43,7 +57,8 @@ function WebPlayer(props: any) { contextValue.player.pause() contextValue.player.jump(jumpTimestamp) contextValue.player.scale() - setTimeout(() => { contextValue.player.showClickmap(insights, onMarkerClick) }, 250) + + setTimeout(() => { contextValue.player.showClickmap(insights) }, 250) }, 500) } return () => { @@ -62,7 +77,6 @@ function WebPlayer(props: any) { export default connect( (state: any) => ({ - session: state.getIn(['sessions', 'current']), insights: state.getIn(['sessions', 'insights']), jwt: state.getIn(['user', 'jwt']), }) diff --git a/frontend/app/components/Session/Player/MobilePlayer/PlayerContent.tsx b/frontend/app/components/Session/Player/MobilePlayer/PlayerContent.tsx index a9e41fd9e..6849e9ce6 100644 --- a/frontend/app/components/Session/Player/MobilePlayer/PlayerContent.tsx +++ b/frontend/app/components/Session/Player/MobilePlayer/PlayerContent.tsx @@ -10,7 +10,6 @@ import PlayerBlock from './PlayerBlock'; const TABS = { EVENTS: 'Activity', - HEATMAPS: 'Click map', }; interface IProps { diff --git a/frontend/app/components/Session/Player/ReplayPlayer/AudioPlayer.tsx b/frontend/app/components/Session/Player/ReplayPlayer/AudioPlayer.tsx index 159e27055..352b9cd88 100644 --- a/frontend/app/components/Session/Player/ReplayPlayer/AudioPlayer.tsx +++ b/frontend/app/components/Session/Player/ReplayPlayer/AudioPlayer.tsx @@ -122,7 +122,6 @@ function DropdownAudioPlayer({ audio.pause(); } if (audio.muted !== isMuted) { - console.log(isMuted, audio.muted); audio.muted = isMuted; } } diff --git a/frontend/app/components/Session/Player/SharedComponents/SessionTabs.tsx b/frontend/app/components/Session/Player/SharedComponents/SessionTabs.tsx index cb685a87e..cd587578c 100644 --- a/frontend/app/components/Session/Player/SharedComponents/SessionTabs.tsx +++ b/frontend/app/components/Session/Player/SharedComponents/SessionTabs.tsx @@ -38,7 +38,7 @@ function Modal({ tabs, currentTab, changeTab, hideModal }: Props) { function SessionTabs({ isLive }: { isLive?: boolean }) { const { showModal, hideModal } = useModal(); const { player, store } = React.useContext(PlayerContext); - const { tabs = new Set('back-compat'), currentTab, closedTabs } = store.get(); + const { tabs = new Set('back-compat'), currentTab, closedTabs, tabNames } = store.get(); const tabsArr = Array.from(tabs).map((tab, idx) => ({ tab, @@ -80,6 +80,7 @@ function SessionTabs({ isLive }: { isLive?: boolean }) { changeTab={changeTab} isLive={isLive} isClosed={tab.isClosed} + name={tabNames[tab.tab]} /> ))} diff --git a/frontend/app/components/Session/Player/SharedComponents/Tab.tsx b/frontend/app/components/Session/Player/SharedComponents/Tab.tsx index 4d3e0f036..8796965ba 100644 --- a/frontend/app/components/Session/Player/SharedComponents/Tab.tsx +++ b/frontend/app/components/Session/Player/SharedComponents/Tab.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import { Tooltip } from 'antd'; import cn from 'classnames'; +import React from 'react'; interface Props { i: number; @@ -8,13 +9,16 @@ interface Props { changeTab?: (tab: string) => void; isLive?: boolean; isClosed?: boolean; + name?: string; } -function Tab({ i, tab, currentTab, changeTab, isLive, isClosed }: Props) { +function Tab({ i, tab, currentTab, changeTab, isLive, isClosed, name }: Props) { return (
changeTab?.(tab)} className={cn( 'self-end py-1 px-4 text-sm', @@ -22,10 +26,27 @@ function Tab({ i, tab, currentTab, changeTab, isLive, isClosed }: Props) { currentTab === tab ? 'border-gray-lighter border-t border-l border-r !border-b-white bg-white rounded-tl rounded-tr font-semibold' : 'cursor-pointer border-gray-lighter !border-b !border-t-transparent !border-l-transparent !border-r-transparent', - isClosed ? 'line-through': '' )} > - Tab {i + 1} + 20 ? name : ''}> +
+
+
{i + 1}
+
+
{name ? name : `Tab ${i + 1}`}
+
+
); } diff --git a/frontend/app/components/Session/WebPlayer.tsx b/frontend/app/components/Session/WebPlayer.tsx index b7e052140..fcc647ed9 100644 --- a/frontend/app/components/Session/WebPlayer.tsx +++ b/frontend/app/components/Session/WebPlayer.tsx @@ -24,7 +24,6 @@ import { const TABS = { EVENTS: 'Activity', - CLICKMAP: 'Click map', INSPECTOR: 'Tag', }; diff --git a/frontend/app/duck/sessions.ts b/frontend/app/duck/sessions.ts index f236d4d5f..3c698d1c3 100644 --- a/frontend/app/duck/sessions.ts +++ b/frontend/app/duck/sessions.ts @@ -215,6 +215,7 @@ const reducer = (state = initialState, action: IAction) => { } }); }); + return state .set('current', session) .set('eventsIndex', matching) diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts index 102b3df32..720bc4bda 100644 --- a/frontend/app/player/web/MessageLoader.ts +++ b/frontend/app/player/web/MessageLoader.ts @@ -161,9 +161,6 @@ export default class MessageLoader { this.messageManager.distributeMessage(msg); }); logger.info('Messages count: ', msgs.length, msgs, file); - if (file === 'd:dom 2' && 'createTabCloseEvents' in this.messageManager) { - this.messageManager.createTabCloseEvents(); - } this.messageManager.sortDomRemoveMessages(msgs); this.messageManager.setMessagesLoading(false); }; @@ -180,6 +177,12 @@ export default class MessageLoader { } } + createTabCloseEvents() { + if ('createTabCloseEvents' in this.messageManager) { + this.messageManager.createTabCloseEvents(); + } + } + preloaded = false; async preloadFirstFile(data: Uint8Array) { this.mobParser = this.createNewParser(true, this.processMessages, 'p:dom'); @@ -242,6 +245,7 @@ export default class MessageLoader { ); } } finally { + this.createTabCloseEvents() this.store.update({ domLoading: false, devtoolsLoading: false }); } } diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index 8183d2b98..43e033690 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -50,6 +50,9 @@ export interface State extends ScreenState { tabStates: { [tabId: string]: TabState; }; + tabNames: { + [tabId: string]: string; + } domContentLoadedTime?: { time: number; value: number }; domBuildingTime?: number; @@ -95,6 +98,7 @@ export default class MessageManager { tabChangeEvents: [], closedTabs: [], sessionStart: 0, + tabNames: {}, }; private clickManager: ListWalker = new ListWalker(); @@ -190,15 +194,20 @@ export default class MessageManager { public createTabCloseEvents = () => { const lastMsgArr: [string, number][] = [] - Object.entries(this.tabs).forEach((entry, i) => { - const [tabId, tab] = entry + const namesObj: Record = {} + for (const [tabId, tab] of Object.entries(this.tabs)) { const { lastMessageTs } = tab - if (lastMessageTs && tabId) lastMsgArr.push([tabId, lastMessageTs]) - }) + if (lastMessageTs && tabId) { + lastMsgArr.push([tabId, lastMessageTs]) + namesObj[tabId] = '' + } + } lastMsgArr.sort((a, b) => a[1] - b[1]) lastMsgArr.forEach(([tabId, lastMessageTs]) => { this.tabCloseManager.append({ tabId, time: lastMessageTs }) }) + + this.state.update({ tabNames: namesObj }) } public startLoading = () => { @@ -331,6 +340,7 @@ export default class MessageManager { case MType.MouseMove: this.mouseMoveManager.append(msg); break; + case MType.MouseClickDeprecated: case MType.MouseClick: this.clickManager.append(msg); break; diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index 0ca1f1479..503c4417b 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -234,8 +234,8 @@ export default class Screen { case ScaleMode.AdjustParentHeight: // we want to scale the document with true height so the clickmap will be scrollable const usedHeight = - this.document?.body.offsetHeight && this.document?.body.offsetHeight > height - ? this.document.body.offsetHeight + 'px' + this.document?.body.scrollHeight && this.document?.body.scrollHeight > height + ? this.document.body.scrollHeight + 'px' : height + 'px'; this.scaleRatio = offsetWidth / width; translate = 'translate(-50%, 0)'; diff --git a/frontend/app/player/web/TabManager.ts b/frontend/app/player/web/TabManager.ts index 7873e0bf1..7c0b5c4d1 100644 --- a/frontend/app/player/web/TabManager.ts +++ b/frontend/app/player/web/TabManager.ts @@ -81,7 +81,7 @@ export default class TabSessionManager { constructor( private session: any, - private readonly state: Store<{ tabStates: { [tabId: string]: TabState } }>, + private readonly state: Store<{ tabStates: { [tabId: string]: TabState }, tabNames: { [tabId: string]: string } }>, private readonly screen: Screen, private readonly id: string, private readonly setSize: ({ height, width }: { height: number; width: number }) => void, @@ -176,7 +176,7 @@ export default class TabSessionManager { switch (msg.tp) { case MType.CanvasNode: const managerId = `${msg.timestamp}_${msg.nodeId}`; - if (!this.canvasManagers[managerId]) { + if (!this.canvasManagers[managerId] && this.session.canvasURL?.length) { const fileId = managerId; const delta = msg.timestamp - this.sessionStart; @@ -198,6 +198,7 @@ export default class TabSessionManager { this.canvasReplayWalker.append(msg); } break; + case MType.SetPageLocationDeprecated: case MType.SetPageLocation: this.locationManager.append(msg); if (msg.navigationStart > 0) { @@ -336,8 +337,12 @@ export default class TabSessionManager { /* === */ const lastLocationMsg = this.locationManager.moveGetLast(t, index); if (!!lastLocationMsg) { + const tabNames = this.state.get().tabNames; + if (lastLocationMsg.documentTitle) { + tabNames[this.id] = lastLocationMsg.documentTitle + } // @ts-ignore comes from parent state - this.state.update({ location: lastLocationMsg.url }); + this.state.update({ location: lastLocationMsg.url, tabNames }); } const lastPerformanceTrackMessage = this.performanceTrackManager.moveGetLast(t, index); diff --git a/frontend/app/player/web/addons/TargetMarker.ts b/frontend/app/player/web/addons/TargetMarker.ts index 452ddd00f..6f90ddec0 100644 --- a/frontend/app/player/web/addons/TargetMarker.ts +++ b/frontend/app/player/web/addons/TargetMarker.ts @@ -1,66 +1,54 @@ -import type Screen from '../Screen/Screen' -import type { Point } from '../Screen/types' -import type { Store } from '../../common/types' -import { clickmapStyles } from './clickmapStyles' - -const zIndexMap = { - 400: 3, - 200: 4, - 100: 5, - 50: 6 -} -const widths = Object.keys(zIndexMap) - .map(s => parseInt(s, 10)) - .sort((a,b) => b - a) as [400, 200, 100, 50] +import type { Store } from '../../common/types'; +import type Screen from '../Screen/Screen'; +import type { Point } from '../Screen/types'; +import { clickmapStyles } from './clickmapStyles'; +import heatmapRenderer from './simpleHeatmap'; function getOffset(el: Element, innerWindow: Window) { - const rect = el.getBoundingClientRect(); - return { - fixedLeft: rect.left + innerWindow.scrollX, - fixedTop: rect.top + innerWindow.scrollY, - rect, - }; + const rect = el.getBoundingClientRect(); + return { + fixedLeft: rect.left + innerWindow.scrollX, + fixedTop: rect.top + innerWindow.scrollY, + rect, + }; } interface BoundingRect { - top: number, - left: number, - width: number, - height: number, + top: number; + left: number; + width: number; + height: number; } export interface MarkedTarget { - boundingRect: BoundingRect, - el: Element, - selector: string, - count: number, - index: number, - active?: boolean, - percent: number + boundingRect: BoundingRect; + el: Element; + selector: string; + count: number; + index: number; + active?: boolean; + percent: number; } export interface State { - markedTargets: MarkedTarget[] | null, - activeTargetIndex: number, + markedTargets: MarkedTarget[] | null; + activeTargetIndex: number; } - export default class TargetMarker { - private clickMapOverlay: HTMLDivElement | null = null - private clickContainers: HTMLDivElement[] = [] - private smallClicks: HTMLDivElement[] = [] - static INITIAL_STATE: State = { - markedTargets: null, - activeTargetIndex: 0 - } + private clickMapOverlay: HTMLCanvasElement | null = null; + static INITIAL_STATE: State = { + markedTargets: null, + activeTargetIndex: 0, + }; - constructor( - private readonly screen: Screen, - private readonly store: Store, - ) {} + constructor( + private readonly screen: Screen, + private readonly store: Store + ) {} - updateMarkedTargets() { - const { markedTargets } = this.store.get() + updateMarkedTargets() { + const { markedTargets } = this.store.get(); if (markedTargets) { this.store.update({ markedTargets: markedTargets.map((mt: any) => ({ @@ -69,55 +57,64 @@ export default class TargetMarker { })), }); } - } - private calculateRelativeBoundingRect(el: Element): BoundingRect { - const parentEl = this.screen.getParentElement() - if (!parentEl) return {top:0, left:0, width:0,height:0} //TODO: can be initialized(?) on mounted screen only - const { top, left, width, height } = el.getBoundingClientRect() - const s = this.screen.getScale() - const screenRect = this.screen.overlay.getBoundingClientRect() //this.screen.getBoundingClientRect() (now private) - const parentRect = parentEl.getBoundingClientRect() - - return { - top: top*s + screenRect.top - parentRect.top, - left: left*s + screenRect.left - parentRect.left, - width: width*s, - height: height*s, + if (heatmapRenderer.checkReady()) { + heatmapRenderer.resize().draw(); } } + private calculateRelativeBoundingRect(el: Element): BoundingRect { + const parentEl = this.screen.getParentElement(); + if (!parentEl) return { top: 0, left: 0, width: 0, height: 0 }; //TODO: can be initialized(?) on mounted screen only + const { top, left, width, height } = el.getBoundingClientRect(); + const s = this.screen.getScale(); + const screenRect = this.screen.overlay.getBoundingClientRect(); //this.screen.getBoundingClientRect() (now private) + const parentRect = parentEl.getBoundingClientRect(); + + return { + top: top * s + screenRect.top - parentRect.top, + left: left * s + screenRect.left - parentRect.left, + width: width * s, + height: height * s, + }; + } + setActiveTarget(index: number) { - const window = this.screen.window - const markedTargets: MarkedTarget[] | null = this.store.get().markedTargets - const target = markedTargets && markedTargets[index] + const window = this.screen.window; + const markedTargets: MarkedTarget[] | null = this.store.get().markedTargets; + const target = markedTargets && markedTargets[index]; if (target && window) { - const { fixedTop, rect } = getOffset(target.el, window) - const scrollToY = fixedTop - window.innerHeight / 1.5 + const { fixedTop, rect } = getOffset(target.el, window); + const scrollToY = fixedTop - window.innerHeight / 1.5; if (rect.top < 0 || rect.top > window.innerHeight) { // behavior hack TODO: fix it somehow when they will decide to remove it from browser api // @ts-ignore - window.scrollTo({ top: scrollToY, behavior: 'instant' }) + window.scrollTo({ top: scrollToY, behavior: 'instant' }); setTimeout(() => { - if (!markedTargets) { return } + if (!markedTargets) { + return; + } this.store.update({ - markedTargets: markedTargets.map(t => t === target ? { - ...target, - boundingRect: this.calculateRelativeBoundingRect(target.el), - } : t) - }) - }, 0) + markedTargets: markedTargets.map((t) => + t === target + ? { + ...target, + boundingRect: this.calculateRelativeBoundingRect(target.el), + } + : t + ), + }); + }, 0); } - } this.store.update({ activeTargetIndex: index }); } - private actualScroll: Point | null = null - markTargets(selections: { selector: string, count: number }[] | null) { + private actualScroll: Point | null = null; + markTargets(selections: { selector: string; count: number }[] | null) { if (selections) { const totalCount = selections.reduce((a, b) => { - return a + b.count + return a + b.count; }, 0); const markedTargets: MarkedTarget[] = []; let index = 0; @@ -130,131 +127,80 @@ export default class TargetMarker { el, index: index++, percent: Math.round((s.count * 100) / totalCount), - boundingRect: this.calculateRelativeBoundingRect(el), + boundingRect: this.calculateRelativeBoundingRect(el), count: s.count, - }) + }); }); - this.actualScroll = this.screen.getCurrentScroll() + this.actualScroll = this.screen.getCurrentScroll(); this.store.update({ markedTargets }); } else { if (this.actualScroll) { - this.screen.window?.scrollTo(this.actualScroll.x, this.actualScroll.y) - this.actualScroll = null + this.screen.window?.scrollTo(this.actualScroll.x, this.actualScroll.y); + this.actualScroll = null; } this.store.update({ markedTargets: null }); } } + injectTargets(clicks: { normalizedX: number; normalizedY: number }[] | null) { + if (clicks && this.screen.document) { + this.clickMapOverlay?.remove(); + const overlay = document.createElement('canvas'); + const iframeSize = this.screen.iframeStylesRef; + const scrollHeight = this.screen.document?.documentElement.scrollHeight || 0; + const scrollWidth = this.screen.document?.documentElement.scrollWidth || 0; + const scaleRatio = this.screen.getScale(); + Object.assign( + overlay.style, + clickmapStyles.overlayStyle({ + height: iframeSize.height, + width: iframeSize.width, + scale: scaleRatio, + }) + ); - injectTargets( - selections: { selector: string, count: number, clickRage?: boolean }[] | null, - onMarkerClick?: (selector: string, innerText: string) => void, - ) { - if (selections) { - const totalCount = selections.reduce((a, b) => { - return a + b.count - }, 0); + this.clickMapOverlay = overlay; + this.screen.getParentElement()?.appendChild(overlay); - this.clickMapOverlay?.remove() - const overlay = document.createElement("div") - const iframeSize = this.screen.iframeStylesRef - const scaleRatio = this.screen.getScale() - Object.assign(overlay.style, clickmapStyles.overlayStyle({ height: iframeSize.height, width: iframeSize.width, scale: scaleRatio })) + const pointMap: Record = {}; + const ovWidth = parseInt(iframeSize.width); + const ovHeight = parseInt(iframeSize.height); + overlay.width = ovWidth; + overlay.height = ovHeight; + let maxIntensity = 0; - this.clickMapOverlay = overlay - selections.forEach((s, i) => { - const el = this.screen.getElementBySelector(s.selector); - if (!el) return; - - const bubbleContainer = document.createElement("div") - const {top, left, width, height} = el.getBoundingClientRect() - const totalClicks = document.createElement("div") - totalClicks.innerHTML = `${s.count} ${s.count !== 1 ? 'Clicks' : 'Click'}` - Object.assign(totalClicks.style, clickmapStyles.totalClicks) - - const percent = document.createElement("div") - percent.style.fontSize = "14px" - percent.innerHTML = `${Math.round((s.count * 100) / totalCount)}% of the clicks recorded in this page` - - bubbleContainer.appendChild(totalClicks) - bubbleContainer.appendChild(percent) - const containerId = `clickmap-bubble-${i}` - bubbleContainer.id = containerId - this.clickContainers.push(bubbleContainer) - const frameWidth = iframeSize.width.replace('px', '') - - // @ts-ignore - Object.assign(bubbleContainer.style, clickmapStyles.bubbleContainer({ top, left: Math.max(100, frameWidth - left > 250 ? left : frameWidth - 220), height })) - - const border = document.createElement("div") - - let key = 0 - - if (width > 50) { - let diff = widths[key] - width - while (diff > 0) { - key++ - diff = widths[key] - width - } + clicks.forEach((point) => { + const key = `${point.normalizedY}-${point.normalizedX}`; + if (pointMap[key]) { + const times = pointMap[key].times + 1; + maxIntensity = Math.max(maxIntensity, times); + pointMap[key].times = times; } else { - key = 3 + const clickData = [ + (point.normalizedX / 100) * scrollWidth, + (point.normalizedY / 100) * scrollHeight, + ]; + pointMap[key] = { times: 1, data: clickData, original: point }; } - const borderZindex = zIndexMap[widths[key]] - - Object.assign(border.style, clickmapStyles.highlight({ width, height, top, left, zIndex: borderZindex })) - - const smallClicksBubble = document.createElement("div") - smallClicksBubble.innerHTML = `${s.count}` - const smallClicksId = containerId + '-small' - smallClicksBubble.id = smallClicksId - this.smallClicks.push(smallClicksBubble) - - border.onclick = (e) => { - e.stopPropagation() - const innerText = el.innerText.length > 25 ? `${el.innerText.slice(0, 20)}...` : el.innerText - onMarkerClick?.(s.selector, innerText) - this.clickContainers.forEach(container => { - if (container.id === containerId) { - container.style.visibility = "visible" - } else { - container.style.visibility = "hidden" - } - }) - this.smallClicks.forEach(container => { - if (container.id !== smallClicksId) { - container.style.visibility = "visible" - } else { - container.style.visibility = "hidden" - } - }) - } - - overlay.onclick = (e) => { - e.stopPropagation() - onMarkerClick?.('', '') - this.clickContainers.forEach(container => { - container.style.visibility = "hidden" - }) - this.smallClicks.forEach(container => { - container.style.visibility = "visible" - }) - } - - Object.assign(smallClicksBubble.style, clickmapStyles.clicks({ top, height, isRage: s.clickRage, left })) - - border.appendChild(smallClicksBubble) - overlay.appendChild(bubbleContainer) - overlay.appendChild(border) }); - this.screen.getParentElement()?.appendChild(overlay) + const heatmapData: number[][] = []; + for (const key in pointMap) { + const { data, times } = pointMap[key]; + heatmapData.push([...data, times]); + } + + heatmapRenderer + .setCanvas(overlay) + .setData(heatmapData) + .setRadius(15, 10) + .setMax(maxIntensity) + .resize() + .draw(); } else { this.store.update({ markedTargets: null }); - this.clickMapOverlay?.remove() - this.clickMapOverlay = null - this.smallClicks = [] - this.clickContainers = [] + this.clickMapOverlay?.remove(); + this.clickMapOverlay = null; } } - } diff --git a/frontend/app/player/web/addons/simpleHeatmap.ts b/frontend/app/player/web/addons/simpleHeatmap.ts new file mode 100644 index 000000000..ac8052a6f --- /dev/null +++ b/frontend/app/player/web/addons/simpleHeatmap.ts @@ -0,0 +1,162 @@ +/** + * modified version of simpleheat + * + * https://github.com/mourner/simpleheat + * Copyright (c) 2015, Vladimir Agafonkin + * + * */ + +class SimpleHeatmap { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D | null; + private width: number; + private height: number; + private max: number; + private data: number[][]; + private circle: HTMLCanvasElement; + private grad: Uint8ClampedArray; + private r: number; + private defaultRadius = 25; + private defaultGradient = { + 0.4: 'blue', + 0.6: 'cyan', + 0.7: 'lime', + 0.8: 'yellow', + 1.0: 'red', + }; + + setCanvas(canvas: HTMLCanvasElement): this { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.width = canvas.width; + this.height = canvas.height; + this.max = 1; + this.data = []; + return this; + } + + setData(data: number[][]): this { + this.data = data; + return this; + } + + setMax(max: number): this { + this.max = max; + return this; + } + + add(point: number[]): this { + this.data.push(point); + return this; + } + + clear(): this { + this.data = []; + return this; + } + + setRadius(r: number, blur: number = 15): this { + const circle = this.createCanvas(); + const ctx = circle.getContext('2d'); + if (!ctx) { + throw new Error('Canvas 2d context is not supported'); + } + const r2 = r + blur; + + circle.width = circle.height = r2 * 2; + + ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2; + ctx.shadowBlur = blur; + ctx.shadowColor = 'black'; + + ctx.beginPath(); + ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.fill(); + + this.circle = circle; + this.r = r2; + + return this; + } + + checkReady(): boolean { + return !!(this.canvas && this.ctx); + } + + resize(): this { + this.width = this.canvas.width; + this.height = this.canvas.height; + + return this; + } + + setGradient(grad: Record): this { + const canvas = this.createCanvas(); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas 2d context is not supported'); + } + const gradient = ctx.createLinearGradient(0, 0, 0, 256); + + canvas.width = 1; + canvas.height = 256; + + for (const i in grad) { + gradient.addColorStop(parseFloat(i), grad[i]); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 1, 256); + + this.grad = ctx.getImageData(0, 0, 1, 256).data; + + return this; + } + + draw(minOpacity: number = 0.05): this { + if (!this.circle) this.setRadius(this.defaultRadius); + if (!this.grad) this.setGradient(this.defaultGradient); + + const ctx = this.ctx; + if (!ctx) { + throw new Error('Canvas 2d context is not supported'); + } + + ctx.clearRect(0, 0, this.width, this.height); + + this.data.forEach((p) => { + ctx.globalAlpha = Math.min(Math.max(p[2] / this.max, minOpacity), 1); + ctx.drawImage(this.circle, p[0] - this.r, p[1] - this.r); + }); + + const colored = ctx.getImageData(0, 0, this.width, this.height); + this.colorize(colored.data, this.grad); + ctx.putImageData(colored, 0, 0); + + return this; + } + + private colorize( + pixels: Uint8ClampedArray, + gradient: Uint8ClampedArray + ): void { + for (let i = 0, len = pixels.length; i < len; i += 4) { + const j = pixels[i + 3] * 4; + + if (j) { + pixels[i] = gradient[j]; + pixels[i + 1] = gradient[j + 1]; + pixels[i + 2] = gradient[j + 2]; + } + } + } + + private createCanvas(): HTMLCanvasElement { + return document.createElement('canvas'); + } +} + +const heatmapRenderer = new SimpleHeatmap(); + +export default heatmapRenderer; diff --git a/frontend/app/player/web/messages/RawMessageReader.gen.ts b/frontend/app/player/web/messages/RawMessageReader.gen.ts index de606ffe8..451859a7e 100644 --- a/frontend/app/player/web/messages/RawMessageReader.gen.ts +++ b/frontend/app/player/web/messages/RawMessageReader.gen.ts @@ -32,7 +32,7 @@ export default class RawMessageReader extends PrimitiveReader { const referrer = this.readString(); if (referrer === null) { return resetPointer() } const navigationStart = this.readUint(); if (navigationStart === null) { return resetPointer() } return { - tp: MType.SetPageLocation, + tp: MType.SetPageLocationDeprecated, url, referrer, navigationStart, @@ -515,13 +515,31 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 68: { + const id = this.readUint(); if (id === null) { return resetPointer() } + const hesitationTime = this.readUint(); if (hesitationTime === null) { return resetPointer() } + const label = this.readString(); if (label === null) { return resetPointer() } + const selector = this.readString(); if (selector === null) { return resetPointer() } + const normalizedX = this.readUint(); if (normalizedX === null) { return resetPointer() } + const normalizedY = this.readUint(); if (normalizedY === null) { return resetPointer() } + return { + tp: MType.MouseClick, + id, + hesitationTime, + label, + selector, + normalizedX, + normalizedY, + }; + } + case 69: { const id = this.readUint(); if (id === null) { return resetPointer() } const hesitationTime = this.readUint(); if (hesitationTime === null) { return resetPointer() } const label = this.readString(); if (label === null) { return resetPointer() } const selector = this.readString(); if (selector === null) { return resetPointer() } return { - tp: MType.MouseClick, + tp: MType.MouseClickDeprecated, id, hesitationTime, label, @@ -763,13 +781,27 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 122: { + const url = this.readString(); if (url === null) { return resetPointer() } + const referrer = this.readString(); if (referrer === null) { return resetPointer() } + const navigationStart = this.readUint(); if (navigationStart === null) { return resetPointer() } + const documentTitle = this.readString(); if (documentTitle === null) { return resetPointer() } + return { + tp: MType.SetPageLocation, + url, + referrer, + navigationStart, + documentTitle, + }; + } + case 93: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const length = this.readUint(); if (length === null) { return resetPointer() } const name = this.readString(); if (name === null) { return resetPointer() } const payload = this.readString(); if (payload === null) { return resetPointer() } return { - tp: MType.IosEvent, + tp: MType.MobileEvent, timestamp, length, name, @@ -785,7 +817,7 @@ export default class RawMessageReader extends PrimitiveReader { const width = this.readUint(); if (width === null) { return resetPointer() } const height = this.readUint(); if (height === null) { return resetPointer() } return { - tp: MType.IosScreenChanges, + tp: MType.MobileScreenChanges, timestamp, length, x, @@ -802,7 +834,7 @@ export default class RawMessageReader extends PrimitiveReader { const x = this.readUint(); if (x === null) { return resetPointer() } const y = this.readUint(); if (y === null) { return resetPointer() } return { - tp: MType.IosClickEvent, + tp: MType.MobileClickEvent, timestamp, length, label, @@ -818,7 +850,7 @@ export default class RawMessageReader extends PrimitiveReader { const valueMasked = this.readBoolean(); if (valueMasked === null) { return resetPointer() } const label = this.readString(); if (label === null) { return resetPointer() } return { - tp: MType.IosInputEvent, + tp: MType.MobileInputEvent, timestamp, length, value, @@ -833,7 +865,7 @@ export default class RawMessageReader extends PrimitiveReader { const name = this.readString(); if (name === null) { return resetPointer() } const value = this.readUint(); if (value === null) { return resetPointer() } return { - tp: MType.IosPerformanceEvent, + tp: MType.MobilePerformanceEvent, timestamp, length, name, @@ -847,7 +879,7 @@ export default class RawMessageReader extends PrimitiveReader { const severity = this.readString(); if (severity === null) { return resetPointer() } const content = this.readString(); if (content === null) { return resetPointer() } return { - tp: MType.IosLog, + tp: MType.MobileLog, timestamp, length, severity, @@ -860,7 +892,7 @@ export default class RawMessageReader extends PrimitiveReader { const length = this.readUint(); if (length === null) { return resetPointer() } const content = this.readString(); if (content === null) { return resetPointer() } return { - tp: MType.IosInternalError, + tp: MType.MobileInternalError, timestamp, length, content, @@ -878,7 +910,7 @@ export default class RawMessageReader extends PrimitiveReader { const status = this.readUint(); if (status === null) { return resetPointer() } const duration = this.readUint(); if (duration === null) { return resetPointer() } return { - tp: MType.IosNetworkCall, + tp: MType.MobileNetworkCall, timestamp, length, type, @@ -899,7 +931,7 @@ export default class RawMessageReader extends PrimitiveReader { const y = this.readUint(); if (y === null) { return resetPointer() } const direction = this.readString(); if (direction === null) { return resetPointer() } return { - tp: MType.IosSwipeEvent, + tp: MType.MobileSwipeEvent, timestamp, length, label, @@ -916,7 +948,7 @@ export default class RawMessageReader extends PrimitiveReader { const context = this.readString(); if (context === null) { return resetPointer() } const payload = this.readString(); if (payload === null) { return resetPointer() } return { - tp: MType.IosIssueEvent, + tp: MType.MobileIssueEvent, timestamp, type, contextString, diff --git a/frontend/app/player/web/messages/filters.gen.ts b/frontend/app/player/web/messages/filters.gen.ts index eca3afb64..d9aa6ca3f 100644 --- a/frontend/app/player/web/messages/filters.gen.ts +++ b/frontend/app/player/web/messages/filters.gen.ts @@ -4,7 +4,7 @@ import { MType } from './raw.gen' const IOS_TYPES = [90,91,92,93,94,95,96,97,98,100,101,102,103,104,105,106,107,110,111] -const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,69,70,71,72,73,74,75,76,77,113,114,117,118,119] +const DOM_TYPES = [0,4,5,6,7,8,9,10,11,12,13,14,15,16,18,19,20,37,38,49,50,51,54,55,57,58,59,60,61,67,68,69,70,71,72,73,74,75,76,77,113,114,117,118,119,122] export function isDOMType(t: MType) { return DOM_TYPES.includes(t) } \ No newline at end of file diff --git a/frontend/app/player/web/messages/message.gen.ts b/frontend/app/player/web/messages/message.gen.ts index cffe1b110..3f6dc2ea1 100644 --- a/frontend/app/player/web/messages/message.gen.ts +++ b/frontend/app/player/web/messages/message.gen.ts @@ -5,7 +5,7 @@ import type { Timed } from './timed' import type { RawMessage } from './raw.gen' import type { RawTimestamp, - RawSetPageLocation, + RawSetPageLocationDeprecated, RawSetViewportSize, RawSetViewportScroll, RawCreateDocument, @@ -46,6 +46,7 @@ import type { RawSetCssDataURLBased, RawCssInsertRuleURLBased, RawMouseClick, + RawMouseClickDeprecated, RawCreateIFrameDocument, RawAdoptedSsReplaceURLBased, RawAdoptedSsReplace, @@ -65,16 +66,17 @@ import type { RawCanvasNode, RawTagTrigger, RawRedux, - RawIosEvent, - RawIosScreenChanges, - RawIosClickEvent, - RawIosInputEvent, - RawIosPerformanceEvent, - RawIosLog, - RawIosInternalError, - RawIosNetworkCall, - RawIosSwipeEvent, - RawIosIssueEvent, + RawSetPageLocation, + RawMobileEvent, + RawMobileScreenChanges, + RawMobileClickEvent, + RawMobileInputEvent, + RawMobilePerformanceEvent, + RawMobileLog, + RawMobileInternalError, + RawMobileNetworkCall, + RawMobileSwipeEvent, + RawMobileIssueEvent, } from './raw.gen' export type Message = RawMessage & Timed @@ -82,7 +84,7 @@ export type Message = RawMessage & Timed export type Timestamp = RawTimestamp & Timed -export type SetPageLocation = RawSetPageLocation & Timed +export type SetPageLocationDeprecated = RawSetPageLocationDeprecated & Timed export type SetViewportSize = RawSetViewportSize & Timed @@ -164,6 +166,8 @@ export type CssInsertRuleURLBased = RawCssInsertRuleURLBased & Timed export type MouseClick = RawMouseClick & Timed +export type MouseClickDeprecated = RawMouseClickDeprecated & Timed + export type CreateIFrameDocument = RawCreateIFrameDocument & Timed export type AdoptedSsReplaceURLBased = RawAdoptedSsReplaceURLBased & Timed @@ -202,23 +206,25 @@ export type TagTrigger = RawTagTrigger & Timed export type Redux = RawRedux & Timed -export type IosEvent = RawIosEvent & Timed +export type SetPageLocation = RawSetPageLocation & Timed -export type IosScreenChanges = RawIosScreenChanges & Timed +export type MobileEvent = RawMobileEvent & Timed -export type IosClickEvent = RawIosClickEvent & Timed +export type MobileScreenChanges = RawMobileScreenChanges & Timed -export type IosInputEvent = RawIosInputEvent & Timed +export type MobileClickEvent = RawMobileClickEvent & Timed -export type IosPerformanceEvent = RawIosPerformanceEvent & Timed +export type MobileInputEvent = RawMobileInputEvent & Timed -export type IosLog = RawIosLog & Timed +export type MobilePerformanceEvent = RawMobilePerformanceEvent & Timed -export type IosInternalError = RawIosInternalError & Timed +export type MobileLog = RawMobileLog & Timed -export type IosNetworkCall = RawIosNetworkCall & Timed +export type MobileInternalError = RawMobileInternalError & Timed -export type IosSwipeEvent = RawIosSwipeEvent & Timed +export type MobileNetworkCall = RawMobileNetworkCall & Timed -export type IosIssueEvent = RawIosIssueEvent & Timed +export type MobileSwipeEvent = RawMobileSwipeEvent & Timed + +export type MobileIssueEvent = RawMobileIssueEvent & Timed diff --git a/frontend/app/player/web/messages/raw.gen.ts b/frontend/app/player/web/messages/raw.gen.ts index 7a9d9e8a5..ddf578aca 100644 --- a/frontend/app/player/web/messages/raw.gen.ts +++ b/frontend/app/player/web/messages/raw.gen.ts @@ -3,7 +3,7 @@ export const enum MType { Timestamp = 0, - SetPageLocation = 4, + SetPageLocationDeprecated = 4, SetViewportSize = 5, SetViewportScroll = 6, CreateDocument = 7, @@ -43,7 +43,8 @@ export const enum MType { SetNodeAttributeURLBased = 60, SetCssDataURLBased = 61, CssInsertRuleURLBased = 67, - MouseClick = 69, + MouseClick = 68, + MouseClickDeprecated = 69, CreateIFrameDocument = 70, AdoptedSsReplaceURLBased = 71, AdoptedSsReplace = 72, @@ -63,16 +64,17 @@ export const enum MType { CanvasNode = 119, TagTrigger = 120, Redux = 121, - IosEvent = 93, - IosScreenChanges = 96, - IosClickEvent = 100, - IosInputEvent = 101, - IosPerformanceEvent = 102, - IosLog = 103, - IosInternalError = 104, - IosNetworkCall = 105, - IosSwipeEvent = 106, - IosIssueEvent = 111, + SetPageLocation = 122, + MobileEvent = 93, + MobileScreenChanges = 96, + MobileClickEvent = 100, + MobileInputEvent = 101, + MobilePerformanceEvent = 102, + MobileLog = 103, + MobileInternalError = 104, + MobileNetworkCall = 105, + MobileSwipeEvent = 106, + MobileIssueEvent = 111, } @@ -81,8 +83,8 @@ export interface RawTimestamp { timestamp: number, } -export interface RawSetPageLocation { - tp: MType.SetPageLocation, +export interface RawSetPageLocationDeprecated { + tp: MType.SetPageLocationDeprecated, url: string, referrer: string, navigationStart: number, @@ -371,6 +373,16 @@ export interface RawMouseClick { hesitationTime: number, label: string, selector: string, + normalizedX: number, + normalizedY: number, +} + +export interface RawMouseClickDeprecated { + tp: MType.MouseClickDeprecated, + id: number, + hesitationTime: number, + label: string, + selector: string, } export interface RawCreateIFrameDocument { @@ -509,16 +521,24 @@ export interface RawRedux { actionTime: number, } -export interface RawIosEvent { - tp: MType.IosEvent, +export interface RawSetPageLocation { + tp: MType.SetPageLocation, + url: string, + referrer: string, + navigationStart: number, + documentTitle: string, +} + +export interface RawMobileEvent { + tp: MType.MobileEvent, timestamp: number, length: number, name: string, payload: string, } -export interface RawIosScreenChanges { - tp: MType.IosScreenChanges, +export interface RawMobileScreenChanges { + tp: MType.MobileScreenChanges, timestamp: number, length: number, x: number, @@ -527,8 +547,8 @@ export interface RawIosScreenChanges { height: number, } -export interface RawIosClickEvent { - tp: MType.IosClickEvent, +export interface RawMobileClickEvent { + tp: MType.MobileClickEvent, timestamp: number, length: number, label: string, @@ -536,8 +556,8 @@ export interface RawIosClickEvent { y: number, } -export interface RawIosInputEvent { - tp: MType.IosInputEvent, +export interface RawMobileInputEvent { + tp: MType.MobileInputEvent, timestamp: number, length: number, value: string, @@ -545,31 +565,31 @@ export interface RawIosInputEvent { label: string, } -export interface RawIosPerformanceEvent { - tp: MType.IosPerformanceEvent, +export interface RawMobilePerformanceEvent { + tp: MType.MobilePerformanceEvent, timestamp: number, length: number, name: string, value: number, } -export interface RawIosLog { - tp: MType.IosLog, +export interface RawMobileLog { + tp: MType.MobileLog, timestamp: number, length: number, severity: string, content: string, } -export interface RawIosInternalError { - tp: MType.IosInternalError, +export interface RawMobileInternalError { + tp: MType.MobileInternalError, timestamp: number, length: number, content: string, } -export interface RawIosNetworkCall { - tp: MType.IosNetworkCall, +export interface RawMobileNetworkCall { + tp: MType.MobileNetworkCall, timestamp: number, length: number, type: string, @@ -581,8 +601,8 @@ export interface RawIosNetworkCall { duration: number, } -export interface RawIosSwipeEvent { - tp: MType.IosSwipeEvent, +export interface RawMobileSwipeEvent { + tp: MType.MobileSwipeEvent, timestamp: number, length: number, label: string, @@ -591,8 +611,8 @@ export interface RawIosSwipeEvent { direction: string, } -export interface RawIosIssueEvent { - tp: MType.IosIssueEvent, +export interface RawMobileIssueEvent { + tp: MType.MobileIssueEvent, timestamp: number, type: string, contextString: string, @@ -601,4 +621,4 @@ export interface RawIosIssueEvent { } -export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawReduxDeprecated | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawWsChannel | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawCanvasNode | RawTagTrigger | RawRedux | RawIosEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosInternalError | RawIosNetworkCall | RawIosSwipeEvent | RawIosIssueEvent; +export type RawMessage = RawTimestamp | RawSetPageLocationDeprecated | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawReduxDeprecated | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawMouseClickDeprecated | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawWsChannel | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawCanvasNode | RawTagTrigger | RawRedux | RawSetPageLocation | RawMobileEvent | RawMobileScreenChanges | RawMobileClickEvent | RawMobileInputEvent | RawMobilePerformanceEvent | RawMobileLog | RawMobileInternalError | RawMobileNetworkCall | RawMobileSwipeEvent | RawMobileIssueEvent; diff --git a/frontend/app/player/web/messages/tracker-legacy.gen.ts b/frontend/app/player/web/messages/tracker-legacy.gen.ts index c8c3e4f1b..e942a32e2 100644 --- a/frontend/app/player/web/messages/tracker-legacy.gen.ts +++ b/frontend/app/player/web/messages/tracker-legacy.gen.ts @@ -4,7 +4,7 @@ import { MType } from './raw.gen' export const TP_MAP = { 0: MType.Timestamp, - 4: MType.SetPageLocation, + 4: MType.SetPageLocationDeprecated, 5: MType.SetViewportSize, 6: MType.SetViewportScroll, 7: MType.CreateDocument, @@ -44,7 +44,8 @@ export const TP_MAP = { 60: MType.SetNodeAttributeURLBased, 61: MType.SetCssDataURLBased, 67: MType.CssInsertRuleURLBased, - 69: MType.MouseClick, + 68: MType.MouseClick, + 69: MType.MouseClickDeprecated, 70: MType.CreateIFrameDocument, 71: MType.AdoptedSsReplaceURLBased, 72: MType.AdoptedSsReplace, @@ -64,14 +65,15 @@ export const TP_MAP = { 119: MType.CanvasNode, 120: MType.TagTrigger, 121: MType.Redux, - 93: MType.IosEvent, - 96: MType.IosScreenChanges, - 100: MType.IosClickEvent, - 101: MType.IosInputEvent, - 102: MType.IosPerformanceEvent, - 103: MType.IosLog, - 104: MType.IosInternalError, - 105: MType.IosNetworkCall, - 106: MType.IosSwipeEvent, - 111: MType.IosIssueEvent, + 122: MType.SetPageLocation, + 93: MType.MobileEvent, + 96: MType.MobileScreenChanges, + 100: MType.MobileClickEvent, + 101: MType.MobileInputEvent, + 102: MType.MobilePerformanceEvent, + 103: MType.MobileLog, + 104: MType.MobileInternalError, + 105: MType.MobileNetworkCall, + 106: MType.MobileSwipeEvent, + 111: MType.MobileIssueEvent, } as const diff --git a/frontend/app/player/web/messages/tracker.gen.ts b/frontend/app/player/web/messages/tracker.gen.ts index 3f2cec06f..18c9a9e84 100644 --- a/frontend/app/player/web/messages/tracker.gen.ts +++ b/frontend/app/player/web/messages/tracker.gen.ts @@ -10,7 +10,7 @@ type TrTimestamp = [ timestamp: number, ] -type TrSetPageLocation = [ +type TrSetPageLocationDeprecated = [ type: 4, url: string, referrer: string, @@ -354,6 +354,16 @@ type TrCSSInsertRuleURLBased = [ ] type TrMouseClick = [ + type: 68, + id: number, + hesitationTime: number, + label: string, + selector: string, + normalizedX: number, + normalizedY: number, +] + +type TrMouseClickDeprecated = [ type: 69, id: number, hesitationTime: number, @@ -522,8 +532,16 @@ type TrRedux = [ actionTime: number, ] +type TrSetPageLocation = [ + type: 122, + url: string, + referrer: string, + navigationStart: number, + documentTitle: string, +] -export type TrackerMessage = TrTimestamp | TrSetPageLocation | 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 | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrWSChannel | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData | TrCanvasNode | TrTagTrigger | TrRedux + +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 | TrGraphQL | 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 export default function translate(tMsg: TrackerMessage): RawMessage | null { switch(tMsg[0]) { @@ -537,7 +555,7 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { case 4: { return { - tp: MType.SetPageLocation, + tp: MType.SetPageLocationDeprecated, url: tMsg[1], referrer: tMsg[2], navigationStart: tMsg[3], @@ -891,13 +909,25 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { } } - case 69: { + case 68: { return { tp: MType.MouseClick, id: tMsg[1], hesitationTime: tMsg[2], label: tMsg[3], selector: tMsg[4], + normalizedX: tMsg[5], + normalizedY: tMsg[6], + } + } + + case 69: { + return { + tp: MType.MouseClickDeprecated, + id: tMsg[1], + hesitationTime: tMsg[2], + label: tMsg[3], + selector: tMsg[4], } } @@ -1058,6 +1088,16 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { } } + case 122: { + return { + tp: MType.SetPageLocation, + url: tMsg[1], + referrer: tMsg[2], + navigationStart: tMsg[3], + documentTitle: tMsg[4], + } + } + default: return null } diff --git a/mobs/messages.rb b/mobs/messages.rb index 54de6030c..7c768e61a 100644 --- a/mobs/messages.rb +++ b/mobs/messages.rb @@ -28,7 +28,9 @@ end message 3, 'SessionEndDeprecated', :tracker => false, :replayer => false do uint 'Timestamp' end -message 4, 'SetPageLocation' do + +# DEPRECATED since 14.0.0 -> goto 122 +message 4, 'SetPageLocationDeprecated' do string 'URL' string 'Referrer' uint 'NavigationStart' @@ -360,8 +362,17 @@ message 67, 'CSSInsertRuleURLBased' do uint 'Index' string 'BaseURL' end -## 68 -message 69, 'MouseClick' do + +message 68, 'MouseClick' do + uint 'ID' + uint 'HesitationTime' + string 'Label' + string 'Selector' + uint 'NormalizedX' + uint 'NormalizedY' +end + +message 69, 'MouseClickDeprecated' do uint 'ID' uint 'HesitationTime' string 'Label' @@ -529,6 +540,13 @@ message 121, 'Redux', :replayer => :devtools do uint 'ActionTime' end +message 122, 'SetPageLocation' do + string 'URL' + string 'Referrer' + uint 'NavigationStart' + string 'DocumentTitle' +end + ## Backend-only message 125, 'IssueEvent', :replayer => false, :tracker => false do uint 'MessageID' diff --git a/tracker/tracker-assist/bun.lockb b/tracker/tracker-assist/bun.lockb index b218a8969..c1bf88d0d 100755 Binary files a/tracker/tracker-assist/bun.lockb and b/tracker/tracker-assist/bun.lockb differ diff --git a/tracker/tracker-assist/package.json b/tracker/tracker-assist/package.json index 6268507e6..18e0c2823 100644 --- a/tracker/tracker-assist/package.json +++ b/tracker/tracker-assist/package.json @@ -31,7 +31,7 @@ "dependencies": { "csstype": "^3.0.10", "fflate": "^0.8.2", - "peerjs": "1.5.1", + "peerjs": "1.5.4", "socket.io-client": "^4.7.2" }, "peerDependencies": { diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md index 728314b2c..15298ce84 100644 --- a/tracker/tracker/CHANGELOG.md +++ b/tracker/tracker/CHANGELOG.md @@ -1,3 +1,9 @@ +# 14.0.0 + +- titles for tabs +- new `MouseClick` message to introduce heatmaps instead of clickmaps +- crossdomain iframe tracking functionality + # 13.0.2 - more file extensions for canvas diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index c2ad9cf4f..dede59288 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "13.0.2", + "version": "14.0.1-6", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts index fb85fd9e8..73c66f16e 100644 --- a/tracker/tracker/src/common/messages.gen.ts +++ b/tracker/tracker/src/common/messages.gen.ts @@ -3,7 +3,7 @@ export declare const enum Type { Timestamp = 0, - SetPageLocation = 4, + SetPageLocationDeprecated = 4, SetViewportSize = 5, SetViewportScroll = 6, CreateDocument = 7, @@ -52,7 +52,8 @@ export declare const enum Type { TechnicalInfo = 63, CustomIssue = 64, CSSInsertRuleURLBased = 67, - MouseClick = 69, + MouseClick = 68, + MouseClickDeprecated = 69, CreateIFrameDocument = 70, AdoptedSSReplaceURLBased = 71, AdoptedSSInsertRuleURLBased = 73, @@ -75,6 +76,7 @@ export declare const enum Type { CanvasNode = 119, TagTrigger = 120, Redux = 121, + SetPageLocation = 122, } @@ -83,8 +85,8 @@ export type Timestamp = [ /*timestamp:*/ number, ] -export type SetPageLocation = [ - /*type:*/ Type.SetPageLocation, +export type SetPageLocationDeprecated = [ + /*type:*/ Type.SetPageLocationDeprecated, /*url:*/ string, /*referrer:*/ string, /*navigationStart:*/ number, @@ -432,6 +434,16 @@ export type MouseClick = [ /*hesitationTime:*/ number, /*label:*/ string, /*selector:*/ string, + /*normalizedX:*/ number, + /*normalizedY:*/ number, +] + +export type MouseClickDeprecated = [ + /*type:*/ Type.MouseClickDeprecated, + /*id:*/ number, + /*hesitationTime:*/ number, + /*label:*/ string, + /*selector:*/ string, ] export type CreateIFrameDocument = [ @@ -595,6 +607,14 @@ export type Redux = [ /*actionTime:*/ number, ] +export type SetPageLocation = [ + /*type:*/ Type.SetPageLocation, + /*url:*/ string, + /*referrer:*/ string, + /*navigationStart:*/ number, + /*documentTitle:*/ string, +] -type Message = Timestamp | SetPageLocation | 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 | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger | Redux + +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 | GraphQL | 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 export default Message diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 4880ac472..dd631f938 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -1,46 +1,45 @@ -import ConditionsManager from '../modules/conditionsManager.js' -import FeatureFlags from '../modules/featureFlags.js' -import Message, { TagTrigger } from './messages.gen.js' -import { - Timestamp, - Metadata, - UserID, - Type as MType, - TabChange, - TabData, - WSChannel, -} from './messages.gen.js' -import { - now, - adjustTimeOrigin, - deprecationWarn, - inIframe, - createEventListener, - deleteEventListener, - requestIdleCb, -} from '../utils.js' -import Nodes from './nodes.js' -import Observer from './observer/top_observer.js' -import Sanitizer from './sanitizer.js' -import Ticker from './ticker.js' -import Logger, { LogLevel, ILogLevel } from './logger.js' -import Session from './session.js' import { gzip } from 'fflate' -import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js' -import AttributeSender from '../modules/attributeSender.js' -import type { Options as ObserverOptions } from './observer/top_observer.js' -import type { Options as SanitizerOptions } from './sanitizer.js' -import type { Options as SessOptions } from './session.js' -import type { Options as NetworkOptions } from '../modules/network.js' -import CanvasRecorder from './canvas.js' -import UserTestManager from '../modules/userTesting/index.js' -import TagWatcher from '../modules/tagWatcher.js' import type { + FromWorkerData, Options as WebworkerOptions, ToWorkerData, - FromWorkerData, } from '../../common/interaction.js' +import AttributeSender from '../modules/attributeSender.js' +import ConditionsManager from '../modules/conditionsManager.js' +import FeatureFlags from '../modules/featureFlags.js' +import type { Options as NetworkOptions } from '../modules/network.js' +import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js' +import TagWatcher from '../modules/tagWatcher.js' +import UserTestManager from '../modules/userTesting/index.js' +import { + adjustTimeOrigin, + createEventListener, + deleteEventListener, + now, + requestIdleCb, + simpleMerge, +} from '../utils.js' +import CanvasRecorder from './canvas.js' +import Logger, { ILogLevel, LogLevel } from './logger.js' +import Message, { + Metadata, + TabChange, + TabData, + TagTrigger, + Timestamp, + Type as MType, + UserID, + WSChannel, +} from './messages.gen.js' +import Nodes from './nodes.js' +import type { Options as ObserverOptions } from './observer/top_observer.js' +import Observer from './observer/top_observer.js' +import type { Options as SanitizerOptions } from './sanitizer.js' +import Sanitizer from './sanitizer.js' +import type { Options as SessOptions } from './session.js' +import Session from './session.js' +import Ticker from './ticker.js' interface TypedWorker extends Omit { postMessage(data: ToWorkerData): void @@ -64,7 +63,7 @@ interface OnStartInfo { const CANCELED = 'canceled' as const const uxtStorageKey = 'or_uxt_active' const bufferStorageKey = 'or_buffer_1' -const START_ERROR = ':(' as const + type SuccessfulStart = OnStartInfo & { success: true } @@ -148,9 +147,19 @@ type AppOptions = { * */ fileExt?: 'webp' | 'png' | 'jpeg' | 'avif' } + crossdomain?: { + /** + * @default false + * */ + enabled?: boolean + /** + * used to send message up, will be '*' by default + * (check your CSP settings) + * @default '*' + * */ + parentDomain?: string + } - /** @deprecated */ - onStart?: StartCallback network?: NetworkOptions } & WebworkerOptions & SessOptions @@ -167,6 +176,22 @@ function getTimezone() { const minutes = Math.abs(offset) % 60 return `UTC${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` } +const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) + +const proto = { + // ask if there are any tabs alive + ask: 'never-gonna-give-you-up', + // response from another tab + resp: 'never-gonna-let-you-down', + // regenerating id (copied other tab) + reg: 'never-gonna-run-around-and-desert-you', + // tracker inside a child iframe + iframeSignal: 'never-gonna-make-you-cry', + // getting node id for child iframe + iframeId: 'never-gonna-say-goodbye', + // batch of messages from an iframe window + iframeBatch: 'never-gonna-tell-a-lie-and-hurt-you', +} as const export default class App { readonly nodes: Nodes @@ -195,13 +220,12 @@ export default class App { private readonly revID: string private activityState: ActivityState = ActivityState.NotActive private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin. - private readonly worker?: TypedWorker + private worker?: TypedWorker public attributeSender: AttributeSender public featureFlags: FeatureFlags public socketMode = false private compressionThreshold = 24 * 1000 - private restartAttempts = 0 private readonly bc: BroadcastChannel | null = null private readonly contextId private canvasRecorder: CanvasRecorder | null = null @@ -209,12 +233,22 @@ export default class App { private conditionsManager: ConditionsManager | null = null private readonly tagWatcher: TagWatcher + private canStart = false + private rootId: number | null = null + private pageFrames: HTMLIFrameElement[] = [] + private frameOderNumber = 0 + private readonly initialHostName = location.hostname + constructor( projectKey: string, sessionToken: string | undefined, options: Partial, private readonly signalError: (error: string, apis: string[]) => void, + private readonly insideIframe: boolean, ) { + this.contextId = Math.random().toString(36).slice(2) + this.projectKey = projectKey + if ( Object.keys(options).findIndex((k) => ['fixedCanvasScaling', 'disableCanvas'].includes(k)) !== -1 @@ -232,45 +266,52 @@ export default class App { } } - this.contextId = Math.random().toString(36).slice(2) - this.projectKey = projectKey this.networkOptions = options.network - this.options = Object.assign( - { - revID: '', - node_id: '__openreplay_id', - session_token_key: '__openreplay_token', - session_pageno_key: '__openreplay_pageno', - session_reset_key: '__openreplay_reset', - session_tabid_key: '__openreplay_tabid', - local_uuid_key: '__openreplay_uuid', - ingestPoint: DEFAULT_INGEST_POINT, - resourceBaseHref: null, - __is_snippet: false, - __debug_report_edp: null, - __debug__: LogLevel.Silent, - __save_canvas_locally: false, - localStorage: null, - sessionStorage: null, - disableStringDict: false, - forceSingleTab: false, - assistSocketHost: '', - fixedCanvasScaling: false, - disableCanvas: false, - assistOnly: false, - canvas: { - disableCanvas: false, - fixedCanvasScaling: false, - __save_canvas_locally: false, - useAnimationFrame: false, - }, - }, - options, - ) - if (!this.options.forceSingleTab && globalThis && 'BroadcastChannel' in globalThis) { + const defaultOptions: Options = { + revID: '', + node_id: '__openreplay_id', + session_token_key: '__openreplay_token', + session_pageno_key: '__openreplay_pageno', + session_reset_key: '__openreplay_reset', + session_tabid_key: '__openreplay_tabid', + local_uuid_key: '__openreplay_uuid', + ingestPoint: DEFAULT_INGEST_POINT, + resourceBaseHref: null, + __is_snippet: false, + __debug_report_edp: null, + __debug__: LogLevel.Silent, + __save_canvas_locally: false, + localStorage: null, + sessionStorage: null, + disableStringDict: false, + forceSingleTab: false, + assistSocketHost: '', + fixedCanvasScaling: false, + disableCanvas: false, + captureIFrames: true, + obscureTextEmails: true, + obscureTextNumbers: false, + crossdomain: { + parentDomain: '*', + }, + canvas: { + disableCanvas: false, + fixedCanvasScaling: false, + __save_canvas_locally: false, + useAnimationFrame: false, + }, + } + this.options = simpleMerge(defaultOptions, options) + + if ( + !this.insideIframe && + !this.options.forceSingleTab && + globalThis && + 'BroadcastChannel' in globalThis + ) { const host = location.hostname.split('.').slice(-2).join('_') - this.bc = inIframe() ? null : new BroadcastChannel(`rick_${host}`) + this.bc = new BroadcastChannel(`rick_${host}`) } this.revID = this.options.revID @@ -303,78 +344,147 @@ export default class App { this.session.applySessionHash(sessionToken) } - try { - this.worker = new Worker( - URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })), - ) - this.worker.onerror = (e) => { - this._debug('webworker_error', e) - } - this.worker.onmessage = ({ data }: MessageEvent) => { - // handling 401 auth restart (new token assignment) - if (data === 'a_stop') { - this.stop(false) - } else if (data === 'a_start') { - void this.start({}, true) - } else if (data === 'not_init') { - this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker') - } else if (data.type === 'failure') { - this.stop(false) - this.debug.error('worker_failed', data.reason) - this._debug('worker_failed', data.reason) - } else if (data.type === 'compress') { - const batch = data.batch - const batchSize = batch.byteLength - if (batchSize > this.compressionThreshold) { - gzip(data.batch, { mtime: 0 }, (err, result) => { - if (err) { - this.debug.error('Openreplay compression error:', err) - this.worker?.postMessage({ type: 'uncompressed', batch: batch }) - } else { - this.worker?.postMessage({ type: 'compressed', batch: result }) - } - }) - } else { - this.worker?.postMessage({ type: 'uncompressed', batch: batch }) - } - } else if (data.type === 'queue_empty') { - this.onSessionSent() - } - } - const alertWorker = () => { - if (this.worker) { - this.worker.postMessage(null) - } - } - // keep better tactics, discard others? - this.attachEventListener(window, 'beforeunload', alertWorker, false) - this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false) - // TODO: stop session after inactivity timeout (make configurable) - this.attachEventListener(document, 'visibilitychange', alertWorker, false) - } catch (e) { - this._debug('worker_start', e) - } + this.initWorker() const thisTab = this.session.getTabId() - const proto = { - // ask if there are any tabs alive - ask: 'never-gonna-give-you-up', - // yes, there are someone out there - resp: 'never-gonna-let-you-down', - // you stole someone's identity - reg: 'never-gonna-run-around-and-desert-you', - } as const + if (!this.insideIframe) { + /** + * if we get a signal from child iframes, we check for their node_id and send it back, + * so they can act as if it was just a same-domain iframe + * */ + let crossdomainFrameCount = 0 + const catchIframeMessage = (event: MessageEvent) => { + const { data } = event + if (data.line === proto.iframeSignal) { + const childIframeDomain = data.domain + const pageIframes = Array.from(document.querySelectorAll('iframe')) + this.pageFrames = pageIframes + const signalId = async () => { + let tries = 0 + while (tries < 10) { + const id = this.checkNodeId(pageIframes, childIframeDomain) + if (id) { + this.waitStarted() + .then(() => { + crossdomainFrameCount++ + const token = this.session.getSessionToken() + const iframeData = { + line: proto.iframeId, + context: this.contextId, + domain: childIframeDomain, + id, + token, + frameOrderNumber: crossdomainFrameCount, + } + this.debug.log('iframe_data', iframeData) + // @ts-ignore + event.source?.postMessage(iframeData, '*') + }) + .catch(console.error) + tries = 10 + break + } + tries++ + await delay(100) + } + } + void signalId() + } + /** + * proxying messages from iframe to main body, so they can be in one batch (same indexes, etc) + * plus we rewrite some of the messages to be relative to the main context/window + * */ + if (data.line === proto.iframeBatch) { + const msgBatch = data.messages + const mappedMessages: Message[] = msgBatch.map((msg: Message) => { + if (msg[0] === MType.MouseMove) { + let fixedMessage = msg + this.pageFrames.forEach((frame) => { + if (frame.dataset.domain === event.data.domain) { + const [type, x, y] = msg + const { left, top } = frame.getBoundingClientRect() + fixedMessage = [type, x + left, y + top] + } + }) + return fixedMessage + } + if (msg[0] === MType.MouseClick) { + let fixedMessage = msg + this.pageFrames.forEach((frame) => { + if (frame.dataset.domain === event.data.domain) { + const [type, id, hesitationTime, label, selector, normX, normY] = msg + const { left, top, width, height } = frame.getBoundingClientRect() - if (this.bc) { + const contentWidth = document.documentElement.scrollWidth + const contentHeight = document.documentElement.scrollHeight + // (normalizedX * frameWidth + frameLeftOffset)/docSize + const fullX = (normX / 100) * width + left + const fullY = (normY / 100) * height + top + const fixedX = fullX / contentWidth + const fixedY = fullY / contentHeight + + fixedMessage = [ + type, + id, + hesitationTime, + label, + selector, + Math.round(fixedX * 1e3) / 1e1, + Math.round(fixedY * 1e3) / 1e1, + ] + } + }) + return fixedMessage + } + return msg + }) + this.messages.push(...mappedMessages) + } + } + window.addEventListener('message', catchIframeMessage) + this.attachStopCallback(() => { + window.removeEventListener('message', catchIframeMessage) + }) + } else { + const catchParentMessage = (event: MessageEvent) => { + const { data } = event + if (data.line !== proto.iframeId) { + return + } + this.rootId = data.id + this.session.setSessionToken(data.token as string) + this.frameOderNumber = data.frameOrderNumber + this.debug.log('starting iframe tracking', data) + this.allowAppStart() + } + window.addEventListener('message', catchParentMessage) + this.attachStopCallback(() => { + window.removeEventListener('message', catchParentMessage) + }) + // communicating with parent window, + // even if its crossdomain is possible via postMessage api + const domain = this.initialHostName + window.parent.postMessage( + { + line: proto.iframeSignal, + source: thisTab, + context: this.contextId, + domain, + }, + '*', + ) + } + + if (this.bc !== null) { this.bc.postMessage({ line: proto.ask, source: thisTab, context: this.contextId, }) - } - - if (this.bc !== null) { + this.startTimeout = setTimeout(() => { + this.allowAppStart() + }, 500) this.bc.onmessage = (ev: MessageEvent) => { if (ev.data.context === this.contextId) { return @@ -382,11 +492,13 @@ export default class App { if (ev.data.line === proto.resp) { const sessionToken = ev.data.token this.session.setSessionToken(sessionToken) + this.allowAppStart() } if (ev.data.line === proto.reg) { const sessionToken = ev.data.token this.session.regenerateTabId() this.session.setSessionToken(sessionToken) + this.allowAppStart() } if (ev.data.line === proto.ask) { const token = this.session.getSessionToken() @@ -403,6 +515,83 @@ export default class App { } } + startTimeout: ReturnType | null = null + private allowAppStart() { + this.canStart = true + if (this.startTimeout) { + clearTimeout(this.startTimeout) + this.startTimeout = null + } + } + + private checkNodeId(iframes: HTMLIFrameElement[], domain: string) { + for (const iframe of iframes) { + if (iframe.dataset.domain === domain) { + // @ts-ignore + return iframe[this.options.node_id] as number | undefined + } + } + return null + } + private initWorker() { + try { + this.worker = new Worker( + URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })), + ) + this.worker.onerror = (e) => { + this._debug('webworker_error', e) + } + this.worker.onmessage = ({ data }: MessageEvent) => { + this.handleWorkerMsg(data) + } + + const alertWorker = () => { + if (this.worker) { + this.worker.postMessage(null) + } + } + // keep better tactics, discard others? + this.attachEventListener(window, 'beforeunload', alertWorker, false) + this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false) + // TODO: stop session after inactivity timeout (make configurable) + this.attachEventListener(document, 'visibilitychange', alertWorker, false) + } catch (e) { + this._debug('worker_start', e) + } + } + + private handleWorkerMsg(data: FromWorkerData) { + // handling 401 auth restart (new token assignment) + if (data === 'a_stop') { + this.stop(false) + } else if (data === 'a_start') { + void this.start({}, true) + } else if (data === 'not_init') { + this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker') + } else if (data.type === 'failure') { + this.stop(false) + this.debug.error('worker_failed', data.reason) + this._debug('worker_failed', data.reason) + } else if (data.type === 'compress') { + const batch = data.batch + const batchSize = batch.byteLength + if (batchSize > this.compressionThreshold) { + gzip(data.batch, { mtime: 0 }, (err, result) => { + if (err) { + this.debug.error('Openreplay compression error:', err) + this.worker?.postMessage({ type: 'uncompressed', batch: batch }) + } else { + this.worker?.postMessage({ type: 'compressed', batch: result }) + } + }) + } else { + this.worker?.postMessage({ type: 'uncompressed', batch: batch }) + } + } else if (data.type === 'queue_empty') { + this.onSessionSent() + } + } + private _debug(context: string, e: any) { if (this.options.__debug_report_edp !== null) { void fetch(this.options.__debug_report_edp, { @@ -418,22 +607,10 @@ export default class App { this.debug.error('OpenReplay error: ', context, e) } - private _usingOldFetchPlugin = false - send(message: Message, urgent = false): void { if (this.activityState === ActivityState.NotActive) { return } - // === Back compatibility with Fetch/Axios plugins === - if (message[0] === MType.Fetch) { - this._usingOldFetchPlugin = true - deprecationWarn('Fetch plugin', "'network' init option", '/installation/network-options') - deprecationWarn('Axios plugin', "'network' init option", '/installation/network-options') - } - if (this._usingOldFetchPlugin && message[0] === MType.NetworkRequest) { - return - } - // ==================================================== if (this.activityState === ActivityState.ColdStart) { this.bufferedMessages1.push(message) @@ -466,23 +643,38 @@ export default class App { this.messages.length = 0 return } - if (this.worker !== undefined && this.messages.length) { - try { - requestIdleCb(() => { - this.messages.unshift(TabData(this.session.getTabId())) - this.messages.unshift(Timestamp(this.timestamp())) - // why I need to add opt chaining? - this.worker?.postMessage(this.messages) - this.commitCallbacks.forEach((cb) => cb(this.messages)) - this.messages.length = 0 - }) - } catch (e) { - this._debug('worker_commit', e) - this.stop(true) - setTimeout(() => { - void this.start() - }, 500) - } + if (this.worker === undefined || !this.messages.length) { + return + } + + if (this.insideIframe) { + window.parent.postMessage( + { + line: proto.iframeBatch, + messages: this.messages, + domain: this.initialHostName, + }, + '*', + ) + this.commitCallbacks.forEach((cb) => cb(this.messages)) + this.messages.length = 0 + return + } + try { + requestIdleCb(() => { + this.messages.unshift(TabData(this.session.getTabId())) + this.messages.unshift(Timestamp(this.timestamp())) + // why I need to add opt chaining? + this.worker?.postMessage(this.messages) + this.commitCallbacks.forEach((cb) => cb(this.messages)) + this.messages.length = 0 + }) + } catch (e) { + this._debug('worker_commit', e) + this.stop(true) + setTimeout(() => { + void this.start() + }, 500) } } @@ -569,14 +761,14 @@ export default class App { if (useSafe) { listener = this.safe(listener) } - this.attachStartCallback( - () => (target ? createEventListener(target, type, listener, useCapture) : null), - useSafe, - ) - this.attachStopCallback( - () => (target ? deleteEventListener(target, type, listener, useCapture) : null), - useSafe, - ) + + const createListener = () => + target ? createEventListener(target, type, listener, useCapture) : null + const deleteListener = () => + target ? deleteEventListener(target, type, listener, useCapture) : null + + this.attachStartCallback(createListener, useSafe) + this.attachStopCallback(deleteListener, useSafe) } // TODO: full correct semantic @@ -705,63 +897,15 @@ export default class App { /** * start buffering messages without starting the actual session, which gives - * user 30 seconds to "activate" and record session by calling `start()` on conditional trigger + * user 30 seconds to "activate" and record session by calling `start()` on conditional trigger, * and we will then send buffered batch, so it won't get lost * */ public async coldStart(startOpts: StartOptions = {}, conditional?: boolean) { this.singleBuffer = false const second = 1000 - if (conditional) { - this.conditionsManager = new ConditionsManager(this, startOpts) - } const isNewSession = this.checkSessionToken(startOpts.forceNew) if (conditional) { - const r = await fetch(this.options.ingestPoint + '/v1/web/start', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...this.getTrackerInfo(), - timestamp: now(), - doNotRecord: true, - bufferDiff: 0, - userID: this.session.getInfo().userID, - token: undefined, - deviceMemory, - jsHeapSizeLimit, - timezone: getTimezone(), - width: window.innerWidth, - height: window.innerHeight, - }), - }) - const { - // this token is needed to fetch conditions and flags, - // but it can't be used to record a session - token, - userBrowser, - userCity, - userCountry, - userDevice, - userOS, - userState, - projectID, - } = await r.json() - this.session.assign({ projectID }) - this.session.setUserInfo({ - userBrowser, - userCity, - userCountry, - userDevice, - userOS, - userState, - }) - const onStartInfo = { sessionToken: token, userUUID: '', sessionID: '' } - this.startCallbacks.forEach((cb) => cb(onStartInfo)) - await this.conditionsManager?.fetchConditions(projectID as string, token as string) - await this.featureFlags.reloadFlags(token as string) - await this.tagWatcher.fetchTags(this.options.ingestPoint, token as string) - this.conditionsManager?.processFlags(this.featureFlags.flags) + await this.setupConditionalStart(startOpts) } const cycle = () => { this.orderNumber += 1 @@ -802,6 +946,56 @@ export default class App { cycle() } + private async setupConditionalStart(startOpts: StartOptions) { + this.conditionsManager = new ConditionsManager(this, startOpts) + const r = await fetch(this.options.ingestPoint + '/v1/web/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...this.getTrackerInfo(), + timestamp: now(), + doNotRecord: true, + bufferDiff: 0, + userID: this.session.getInfo().userID, + token: undefined, + deviceMemory, + jsHeapSizeLimit, + timezone: getTimezone(), + width: window.innerWidth, + height: window.innerHeight, + }), + }) + const { + // this token is needed to fetch conditions and flags, + // but it can't be used to record a session + token, + userBrowser, + userCity, + userCountry, + userDevice, + userOS, + userState, + projectID, + } = await r.json() + this.session.assign({ projectID }) + this.session.setUserInfo({ + userBrowser, + userCity, + userCountry, + userDevice, + userOS, + userState, + }) + const onStartInfo = { sessionToken: token, userUUID: '', sessionID: '' } + this.startCallbacks.forEach((cb) => cb(onStartInfo)) + await this.conditionsManager?.fetchConditions(projectID as string, token as string) + await this.featureFlags.reloadFlags(token as string) + await this.tagWatcher.fetchTags(this.options.ingestPoint, token as string) + this.conditionsManager?.processFlags(this.featureFlags.flags) + } + onSessionSent = () => { return } @@ -855,7 +1049,7 @@ export default class App { /** * Saves the captured messages in localStorage (or whatever is used in its place) * - * Then when this.offlineRecording is called, it will preload this messages and clear the storage item + * Then, when this.offlineRecording is called, it will preload this messages and clear the storage item * * Keeping the size of local storage reasonable is up to the end users of this library * */ @@ -946,7 +1140,7 @@ export default class App { this.clearBuffers() } - private _start( + private async _start( startOpts: StartOptions = {}, resetByWorker = false, conditionName?: string, @@ -1008,8 +1202,8 @@ export default class App { 'session token: ', sessionToken, ) - return window - .fetch(this.options.ingestPoint + '/v1/web/start', { + try { + const r = await window.fetch(this.options.ingestPoint + '/v1/web/start', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1028,190 +1222,173 @@ export default class App { assistOnly: startOpts.assistOnly ?? this.socketMode, }), }) - .then((r) => { - if (r.status === 200) { - return r.json() - } else { - return r - .text() - .then((text) => - text === CANCELED - ? Promise.reject(CANCELED) - : Promise.reject(`Server error: ${r.status}. ${text}`), - ) - } + if (r.status !== 200) { + const error = await r.text() + const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}` + return Promise.reject(reason) + } + if (!this.worker) { + const reason = 'no worker found after start request (this might not happen)' + this.signalError(reason, []) + return Promise.reject(reason) + } + const { + token, + userUUID, + projectID, + beaconSizeLimit, + compressionThreshold, // how big the batch should be before we decide to compress it + delay, // derived from token + sessionID, // derived from token + startTimestamp, // real startTS (server time), derived from sessionID + userBrowser, + userCity, + userCountry, + userDevice, + userOS, + userState, + canvasEnabled, + canvasQuality, + canvasFPS, + assistOnly: socketOnly, + } = await r.json() + + if ( + typeof token !== 'string' || + typeof userUUID !== 'string' || + (typeof startTimestamp !== 'number' && typeof startTimestamp !== 'undefined') || + typeof sessionID !== 'string' || + typeof delay !== 'number' || + (typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined') + ) { + const reason = `Incorrect server response: ${JSON.stringify(r)}` + this.signalError(reason, []) + return Promise.reject(reason) + } + + this.delay = delay + this.session.setSessionToken(token) + this.session.setUserInfo({ + userBrowser, + userCity, + userCountry, + userDevice, + userOS, + userState, }) - .then(async (r) => { - if (!this.worker) { - const reason = 'no worker found after start request (this might not happen)' - this.signalError(reason, []) - return Promise.reject(reason) - } - if (this.activityState === ActivityState.NotActive) { - const reason = 'Tracker stopped during authorization' - this.signalError(reason, []) - return Promise.reject(reason) - } - const { + this.session.assign({ + sessionID, + timestamp: startTimestamp || timestamp, + projectID, + }) + + if (socketOnly) { + this.socketMode = true + this.worker.postMessage('stop') + } else { + this.worker.postMessage({ + type: 'auth', token, - userUUID, - projectID, beaconSizeLimit, - compressionThreshold, // how big the batch should be before we decide to compress it - delay, // derived from token - sessionID, // derived from token - startTimestamp, // real startTS (server time), derived from sessionID - userBrowser, - userCity, - userCountry, - userDevice, - userOS, - userState, - canvasEnabled, - canvasQuality, - canvasFPS, - assistOnly: socketOnly, - } = r - if ( - typeof token !== 'string' || - typeof userUUID !== 'string' || - (typeof startTimestamp !== 'number' && typeof startTimestamp !== 'undefined') || - typeof sessionID !== 'string' || - typeof delay !== 'number' || - (typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined') - ) { - const reason = `Incorrect server response: ${JSON.stringify(r)}` - this.signalError(reason, []) - return Promise.reject(reason) - } - this.delay = delay - this.session.setSessionToken(token) - this.session.setUserInfo({ - userBrowser, - userCity, - userCountry, - userDevice, - userOS, - userState, - }) - this.session.assign({ - sessionID, - timestamp: startTimestamp || timestamp, - projectID, }) + } - if (socketOnly) { - this.socketMode = true - this.worker.postMessage('stop') - } else { - this.worker.postMessage({ - type: 'auth', - token, - beaconSizeLimit, + if (!isNewSession && token === sessionToken) { + this.debug.log('continuing session on new tab', this.session.getTabId()) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.send(TabChange(this.session.getTabId())) + } + // (Re)send Metadata for the case of a new session + Object.entries(this.session.getInfo().metadata).forEach(([key, value]) => + this.send(Metadata(key, value)), + ) + this.localStorage.setItem(this.options.local_uuid_key, userUUID) + + this.compressionThreshold = compressionThreshold + const onStartInfo = { sessionToken: token, userUUID, sessionID } + // TODO: start as early as possible (before receiving the token) + /** after start */ + this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) + void this.featureFlags.reloadFlags() + await this.tagWatcher.fetchTags(this.options.ingestPoint, token) + this.activityState = ActivityState.Active + + if (canvasEnabled && !this.options.canvas.disableCanvas) { + this.canvasRecorder = + this.canvasRecorder ?? + new CanvasRecorder(this, { + fps: canvasFPS, + quality: canvasQuality, + isDebug: this.options.canvas.__save_canvas_locally, + fixedScaling: this.options.canvas.fixedCanvasScaling, + useAnimationFrame: this.options.canvas.useAnimationFrame, }) + this.canvasRecorder.startTracking() + } + + /** --------------- COLD START BUFFER ------------------*/ + if (isColdStart) { + const biggestBuffer = + this.bufferedMessages1.length > this.bufferedMessages2.length + ? this.bufferedMessages1 + : this.bufferedMessages2 + while (biggestBuffer.length > 0) { + await this.flushBuffer(biggestBuffer) } - - if (!isNewSession && token === sessionToken) { - this.debug.log('continuing session on new tab', this.session.getTabId()) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.send(TabChange(this.session.getTabId())) - } - // (Re)send Metadata for the case of a new session - Object.entries(this.session.getInfo().metadata).forEach(([key, value]) => - this.send(Metadata(key, value)), - ) - this.localStorage.setItem(this.options.local_uuid_key, userUUID) - - this.compressionThreshold = compressionThreshold - const onStartInfo = { sessionToken: token, userUUID, sessionID } - // TODO: start as early as possible (before receiving the token) - /** after start */ - this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) - void this.featureFlags.reloadFlags() - await this.tagWatcher.fetchTags(this.options.ingestPoint, token) - this.activityState = ActivityState.Active - - if (canvasEnabled && !this.options.canvas.disableCanvas) { - this.canvasRecorder = - this.canvasRecorder ?? - new CanvasRecorder(this, { - fps: canvasFPS, - quality: canvasQuality, - isDebug: this.options.canvas.__save_canvas_locally, - fixedScaling: this.options.canvas.fixedCanvasScaling, - useAnimationFrame: this.options.canvas.useAnimationFrame, - }) - this.canvasRecorder.startTracking() - } - + this.clearBuffers() + this.commit() /** --------------- COLD START BUFFER ------------------*/ - if (isColdStart) { - const biggestBuffer = - this.bufferedMessages1.length > this.bufferedMessages2.length - ? this.bufferedMessages1 - : this.bufferedMessages2 - while (biggestBuffer.length > 0) { - await this.flushBuffer(biggestBuffer) - } - this.clearBuffers() - this.commit() - /** --------------- COLD START BUFFER ------------------*/ + } else { + if (this.insideIframe && this.rootId) { + this.observer.crossdomainObserve(this.rootId, this.frameOderNumber) } else { this.observer.observe() - this.ticker.start() } + this.ticker.start() + } - // get rid of onStart ? - if (typeof this.options.onStart === 'function') { - this.options.onStart(onStartInfo) + this.uxtManager = this.uxtManager ? this.uxtManager : new UserTestManager(this, uxtStorageKey) + let uxtId: number | undefined + const savedUxtTag = this.localStorage.getItem(uxtStorageKey) + if (savedUxtTag) { + uxtId = parseInt(savedUxtTag, 10) + } + if (location?.search) { + const query = new URLSearchParams(location.search) + if (query.has('oruxt')) { + const qId = query.get('oruxt') + uxtId = qId ? parseInt(qId, 10) : undefined } - this.restartAttempts = 0 + } - this.uxtManager = this.uxtManager - ? this.uxtManager - : new UserTestManager(this, uxtStorageKey) - let uxtId: number | undefined - const savedUxtTag = this.localStorage.getItem(uxtStorageKey) - if (savedUxtTag) { - uxtId = parseInt(savedUxtTag, 10) - } - if (location?.search) { - const query = new URLSearchParams(location.search) - if (query.has('oruxt')) { - const qId = query.get('oruxt') - uxtId = qId ? parseInt(qId, 10) : undefined - } + if (uxtId) { + if (!this.uxtManager.isActive) { + // eslint-disable-next-line + this.uxtManager.getTest(uxtId, token, Boolean(savedUxtTag)).then((id) => { + if (id) { + this.onUxtCb.forEach((cb: (id: number) => void) => cb(id)) + } + }) + } else { + // @ts-ignore + this.onUxtCb.forEach((cb: (id: number) => void) => cb(uxtId)) } + } - if (uxtId) { - if (!this.uxtManager.isActive) { - // eslint-disable-next-line - this.uxtManager.getTest(uxtId, token, Boolean(savedUxtTag)).then((id) => { - if (id) { - this.onUxtCb.forEach((cb: (id: number) => void) => cb(id)) - } - }) - } else { - // @ts-ignore - this.onUxtCb.forEach((cb: (id: number) => void) => cb(uxtId)) - } - } + return SuccessfulStart(onStartInfo) + } catch (reason) { + this.stop() + this.session.reset() + if (reason === CANCELED) { + this.signalError(CANCELED, []) + return UnsuccessfulStart(CANCELED) + } - return SuccessfulStart(onStartInfo) - }) - .catch((reason) => { - this.stop() - this.session.reset() - if (reason === CANCELED) { - this.signalError(CANCELED, []) - return UnsuccessfulStart(CANCELED) - } - - this._debug('session_start', reason) - const errorMessage = reason instanceof Error ? reason.message : reason.toString() - this.signalError(errorMessage, []) - return UnsuccessfulStart(errorMessage) - }) + this._debug('session_start', reason) + const errorMessage = reason instanceof Error ? reason.message : reason.toString() + this.signalError(errorMessage, []) + return UnsuccessfulStart(errorMessage) + } } restartCanvasTracking = () => { @@ -1246,11 +1423,37 @@ export default class App { return this.uxtManager?.getTestId() } + async waitStart() { + return new Promise((resolve) => { + const check = () => { + if (this.canStart) { + resolve(true) + } else { + setTimeout(check, 25) + } + } + check() + }) + } + + async waitStarted() { + return new Promise((resolve) => { + const check = () => { + if (this.activityState === ActivityState.Active) { + resolve(true) + } else { + setTimeout(check, 25) + } + } + check() + }) + } + /** * basically we ask other tabs during constructor * and here we just apply 10ms delay just in case * */ - start(...args: Parameters): Promise { + async start(...args: Parameters): Promise { if ( this.activityState === ActivityState.Active || this.activityState === ActivityState.Starting @@ -1261,21 +1464,19 @@ export default class App { } if (!document.hidden) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(this._start(...args)) - }, 25) - }) + await this.waitStart() + return this._start(...args) } else { return new Promise((resolve) => { - const onVisibilityChange = () => { + const onVisibilityChange = async () => { if (!document.hidden) { + await this.waitStart() + // eslint-disable-next-line document.removeEventListener('visibilitychange', onVisibilityChange) - setTimeout(() => { - resolve(this._start(...args)) - }, 25) + resolve(this._start(...args)) } } + // eslint-disable-next-line document.addEventListener('visibilitychange', onVisibilityChange) }) } diff --git a/tracker/tracker/src/main/app/messages.gen.ts b/tracker/tracker/src/main/app/messages.gen.ts index 59fba36af..60e36ca38 100644 --- a/tracker/tracker/src/main/app/messages.gen.ts +++ b/tracker/tracker/src/main/app/messages.gen.ts @@ -14,13 +14,13 @@ export function Timestamp( ] } -export function SetPageLocation( +export function SetPageLocationDeprecated( url: string, referrer: string, navigationStart: number, -): Messages.SetPageLocation { +): Messages.SetPageLocationDeprecated { return [ - Messages.Type.SetPageLocation, + Messages.Type.SetPageLocationDeprecated, url, referrer, navigationStart, @@ -656,6 +656,8 @@ export function MouseClick( hesitationTime: number, label: string, selector: string, + normalizedX: number, + normalizedY: number, ): Messages.MouseClick { return [ Messages.Type.MouseClick, @@ -663,6 +665,23 @@ export function MouseClick( hesitationTime, label, selector, + normalizedX, + normalizedY, + ] +} + +export function MouseClickDeprecated( + id: number, + hesitationTime: number, + label: string, + selector: string, +): Messages.MouseClickDeprecated { + return [ + Messages.Type.MouseClickDeprecated, + id, + hesitationTime, + label, + selector, ] } @@ -966,3 +985,18 @@ export function Redux( ] } +export function SetPageLocation( + url: string, + referrer: string, + navigationStart: number, + documentTitle: string, +): Messages.SetPageLocation { + return [ + Messages.Type.SetPageLocation, + url, + referrer, + navigationStart, + documentTitle, + ] +} + diff --git a/tracker/tracker/src/main/app/nodes.ts b/tracker/tracker/src/main/app/nodes.ts index b2b9cc136..f59e1ff89 100644 --- a/tracker/tracker/src/main/app/nodes.ts +++ b/tracker/tracker/src/main/app/nodes.ts @@ -8,9 +8,22 @@ export default class Nodes { private totalNodeAmount = 0 private readonly nodeCallbacks: Array = [] private readonly elementListeners: Map> = new Map() + private nextNodeId = 0 constructor(private readonly node_id: string) {} + syntheticMode(frameOrder: number) { + const maxSafeNumber = 9007199254740900 + const placeholderSize = 99999999 + const nextFrameId = placeholderSize * frameOrder + // I highly doubt that this will ever happen, + // but it will be easier to debug if it does + if (nextFrameId > maxSafeNumber) { + throw new Error('Placeholder id overflow') + } + this.nextNodeId = nextFrameId + } + // Attached once per Tracker instance attachNodeCallback(nodeCallback: NodeCallback): void { this.nodeCallbacks.push(nodeCallback) @@ -38,8 +51,9 @@ export default class Nodes { let id: number = (node as any)[this.node_id] const isNew = id === undefined if (isNew) { + id = this.nextNodeId this.totalNodeAmount++ - id = this.nodes.length + this.nextNodeId++ this.nodes[id] = node ;(node as any)[this.node_id] = id } @@ -102,6 +116,7 @@ export default class Nodes { } this.unregisterNode(node) } + this.nextNodeId = 0 this.nodes.length = 0 } } diff --git a/tracker/tracker/src/main/app/observer/iframe_observer.ts b/tracker/tracker/src/main/app/observer/iframe_observer.ts index 2b73fa0f6..9ffff2cfb 100644 --- a/tracker/tracker/src/main/app/observer/iframe_observer.ts +++ b/tracker/tracker/src/main/app/observer/iframe_observer.ts @@ -18,4 +18,14 @@ export default class IFrameObserver extends Observer { this.app.send(CreateIFrameDocument(hostID, docID)) }) } + + syntheticObserve(selfId: number, doc: Document) { + this.observeRoot(doc, (docID) => { + if (docID === undefined) { + this.app.debug.log('OpenReplay: Iframe document not bound') + return + } + this.app.send(CreateIFrameDocument(selfId, docID)) + }) + } } diff --git a/tracker/tracker/src/main/app/observer/iframe_offsets.ts b/tracker/tracker/src/main/app/observer/iframe_offsets.ts index dc88a9506..46e92142c 100644 --- a/tracker/tracker/src/main/app/observer/iframe_offsets.ts +++ b/tracker/tracker/src/main/app/observer/iframe_offsets.ts @@ -1,5 +1,3 @@ -// Le truc - for defining an absolute offset for (nested) iframe documents. (To track mouse movments) - export type Offset = [/*left:*/ number, /*top: */ number] type OffsetState = { diff --git a/tracker/tracker/src/main/app/observer/top_observer.ts b/tracker/tracker/src/main/app/observer/top_observer.ts index 24cb3e270..041655d25 100644 --- a/tracker/tracker/src/main/app/observer/top_observer.ts +++ b/tracker/tracker/src/main/app/observer/top_observer.ts @@ -140,6 +140,21 @@ export default class TopObserver extends Observer { ) } + crossdomainObserve(selfId: number, frameOder: number) { + const observer = this + Element.prototype.attachShadow = function () { + // eslint-disable-next-line + const shadow = attachShadowNativeFn.apply(this, arguments) + observer.handleShadowRoot(shadow) + return shadow + } + this.app.nodes.clear() + this.app.nodes.syntheticMode(frameOder) + const iframeObserver = new IFrameObserver(this.app) + this.iframeObservers.push(iframeObserver) + iframeObserver.syntheticObserve(selfId, window.document) + } + disconnect() { this.iframeOffsets.clear() Element.prototype.attachShadow = attachShadowNativeFn diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index d1d17011b..1407ca650 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -29,7 +29,7 @@ import ConstructedStyleSheets from './modules/constructedStyleSheets.js' import Selection from './modules/selection.js' import Tabs from './modules/tabs.js' -import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js' +import { IN_BROWSER, deprecationWarn, DOCS_HOST, inIframe } from './utils.js' import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js' import type { Options as AppOptions } from './app/index.js' import type { Options as ConsoleOptions } from './modules/console.js' @@ -99,8 +99,10 @@ export default class API { public featureFlags: FeatureFlags private readonly app: App | null = null + private readonly crossdomainMode: boolean = false constructor(private readonly options: Options) { + this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled) if (!IN_BROWSER || !processOptions(options)) { return } @@ -158,25 +160,38 @@ export default class API { return } - const app = new App(options.projectKey, options.sessionToken, options, this.signalStartIssue) + const app = new App( + options.projectKey, + options.sessionToken, + options, + this.signalStartIssue, + this.crossdomainMode, + ) this.app = app - Viewport(app) + if (!this.crossdomainMode) { + // no need to send iframe viewport data since its a node for us + Viewport(app) + // calculated in main window + Connection(app) + // while we can calculate it here, trying to compute it for all parts is hard + Performance(app, options) + // no tabs in iframes yet + Tabs(app) + } + Mouse(app, options.mouse) + // inside iframe, we ignore viewport scroll + Scroll(app, this.crossdomainMode) CSSRules(app) ConstructedStyleSheets(app) - Connection(app) Console(app, options) Exception(app, options) Img(app) Input(app, options) - Mouse(app, options.mouse) Timing(app, options) - Performance(app, options) - Scroll(app) Focus(app) Fonts(app) Network(app, options.network) Selection(app) - Tabs(app) ;(window as any).__OPENREPLAY__ = this if (options.flags && options.flags.onFlagsLoad) { diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index 38f8765c1..03e5dac7a 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -105,6 +105,7 @@ export interface MouseHandlerOptions { export default function (app: App, options?: MouseHandlerOptions): void { const { disableClickmaps = false } = options || {} + function getTargetLabel(target: Element): string { const dl = getLabelAttribute(target) if (dl !== null) { @@ -203,7 +204,6 @@ export default function (app: App, options?: MouseHandlerOptions): void { mousePositionX = e.clientX + left mousePositionY = e.clientY + top mousePositionChanged = true - const nextDirection = Math.sign(e.movementX) distance += Math.abs(e.movementX) + Math.abs(e.movementY) @@ -221,6 +221,15 @@ export default function (app: App, options?: MouseHandlerOptions): void { } const id = app.nodes.getID(target) if (id !== undefined) { + const clickX = e.pageX + const clickY = e.pageY + + const contentWidth = document.documentElement.scrollWidth + const contentHeight = document.documentElement.scrollHeight + + const normalizedX = roundNumber(clickX / contentWidth) + const normalizedY = roundNumber(clickY / contentHeight) + sendMouseMove() app.send( MouseClick( @@ -228,6 +237,8 @@ export default function (app: App, options?: MouseHandlerOptions): void { mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0, getTargetLabel(target), isClickable(target) && !disableClickmaps ? getSelector(id, target, options) : '', + normalizedX, + normalizedY, ), true, ) @@ -245,3 +256,11 @@ export default function (app: App, options?: MouseHandlerOptions): void { app.ticker.attach(sendMouseMove, options?.trackingOffset || 7) } + +/** + * we get 0 to 1 decimal number, convert and round it, then turn to % + * 0.39643 => 396.43 => 396 => 39.6% + * */ +function roundNumber(num: number) { + return Math.round(num * 1e3) / 1e1 +} diff --git a/tracker/tracker/src/main/modules/scroll.ts b/tracker/tracker/src/main/modules/scroll.ts index 74003cd29..46f901afb 100644 --- a/tracker/tracker/src/main/modules/scroll.ts +++ b/tracker/tracker/src/main/modules/scroll.ts @@ -5,18 +5,18 @@ import { isNode, isElementNode, isRootNode, isDocument } from '../app/guards.js' function getDocumentScroll(doc: Document): [number, number] { const win = doc.defaultView return [ - (win && win.pageXOffset) || + (win && win.scrollX) || (doc.documentElement && doc.documentElement.scrollLeft) || (doc.body && doc.body.scrollLeft) || 0, - (win && win.pageYOffset) || + (win && win.scrollY) || (doc.documentElement && doc.documentElement.scrollTop) || (doc.body && doc.body.scrollTop) || 0, ] } -export default function (app: App): void { +export default function (app: App, insideIframe: boolean | null): void { let documentScroll = false const nodeScroll: Map = new Map() @@ -32,9 +32,12 @@ export default function (app: App): void { } } - const sendSetViewportScroll = app.safe((): void => - app.send(SetViewportScroll(...getDocumentScroll(document))), - ) + const sendSetViewportScroll = app.safe((): void => { + if (insideIframe) { + return + } + app.send(SetViewportScroll(...getDocumentScroll(document))) + }) const sendSetNodeScroll = app.safe((s: [number, number], node: Node): void => { const id = app.nodes.getID(node) diff --git a/tracker/tracker/src/main/modules/viewport.ts b/tracker/tracker/src/main/modules/viewport.ts index 16ed279ab..a17332855 100644 --- a/tracker/tracker/src/main/modules/viewport.ts +++ b/tracker/tracker/src/main/modules/viewport.ts @@ -3,7 +3,7 @@ import { getTimeOrigin } from '../utils.js' import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js' export default function (app: App): void { - let url: string, width: number, height: number + let url: string | null, width: number, height: number let navigationStart: number let referrer = document.referrer @@ -11,7 +11,7 @@ export default function (app: App): void { const { URL } = document if (URL !== url) { url = URL - app.send(SetPageLocation(url, referrer, navigationStart)) + app.send(SetPageLocation(url, referrer, navigationStart, document.title)) navigationStart = 0 referrer = url } @@ -32,7 +32,7 @@ export default function (app: App): void { : app.safe(() => app.send(SetPageVisibility(document.hidden))) app.attachStartCallback(() => { - url = '' + url = null navigationStart = getTimeOrigin() width = height = -1 sendSetPageLocation() diff --git a/tracker/tracker/src/main/utils.ts b/tracker/tracker/src/main/utils.ts index 15946b4a6..1c840ccaa 100644 --- a/tracker/tracker/src/main/utils.ts +++ b/tracker/tracker/src/main/utils.ts @@ -13,9 +13,11 @@ let timeOrigin: number = IN_BROWSER ? Date.now() - performance.now() : 0 export function adjustTimeOrigin() { timeOrigin = Date.now() - performance.now() } + export function getTimeOrigin() { return timeOrigin } + export const now: () => number = IN_BROWSER && !!performance.now ? () => Math.round(performance.now() + timeOrigin) @@ -102,13 +104,17 @@ export function generateRandomId(len?: number) { // msCrypto = IE11 // @ts-ignore const safeCrypto = window.crypto || window.msCrypto - safeCrypto.getRandomValues(arr) - return Array.from(arr, dec2hex).join('') + if (safeCrypto) { + safeCrypto.getRandomValues(arr) + return Array.from(arr, dec2hex).join('') + } else { + return Array.from({ length: len || 40 }, () => dec2hex(Math.floor(Math.random() * 16))).join('') + } } export function inIframe() { try { - return window.self !== window.top + return window.self && window.top && window.self !== window.top } catch (e) { return true } @@ -141,9 +147,10 @@ export function createEventListener( try { target[safeAddEventListener](event, cb, capture) } catch (e) { + const msg = e.message console.debug( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Openreplay: ${e.messages}; if this error is caused by an IframeObserver, ignore it`, + `Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`, ) } } @@ -160,14 +167,14 @@ export function deleteEventListener( try { target[safeRemoveEventListener](event, cb, capture) } catch (e) { + const msg = e.message console.debug( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Openreplay: ${e.messages}; if this error is caused by an IframeObserver, ignore it`, + `Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`, ) } } - class FIFOTaskScheduler { taskQueue: any[] isRunning: boolean @@ -234,3 +241,27 @@ export function requestIdleCb(callback: () => void) { // }) // } } + +export function simpleMerge(defaultObj: T, givenObj: Partial): T { + const result = { ...defaultObj } + + for (const key in givenObj) { + // eslint-disable-next-line no-prototype-builtins + if (givenObj.hasOwnProperty(key)) { + const userOptionValue = givenObj[key] + const defaultOptionValue = defaultObj[key] + + if ( + typeof userOptionValue === 'object' && + !Array.isArray(userOptionValue) && + userOptionValue !== null + ) { + result[key] = simpleMerge(defaultOptionValue || {}, userOptionValue) as any + } else { + result[key] = userOptionValue as any + } + } + } + + return result +} diff --git a/tracker/tracker/src/tests/canvas.test.ts b/tracker/tracker/src/tests/canvas.test.ts new file mode 100644 index 000000000..0bff01106 --- /dev/null +++ b/tracker/tracker/src/tests/canvas.test.ts @@ -0,0 +1,113 @@ +// @ts-nocheck +import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals' +import CanvasRecorder from '../main/app/canvas' +import App from '../main/app/index.js' + +describe('CanvasRecorder', () => { + let appMock: jest.Mocked + let canvasRecorder: CanvasRecorder + let nodeMock: Node + + beforeEach(() => { + // @ts-ignore + appMock = {} + appMock.nodes = { + scanTree: jest.fn(), + attachNodeCallback: jest.fn(), + getID: jest.fn().mockReturnValue(1), + getNode: jest.fn(), + } + appMock.sanitizer = { + isObscured: jest.fn().mockReturnValue(false), + isHidden: jest.fn().mockReturnValue(false), + } + appMock.timestamp = jest.fn().mockReturnValue(1000) + appMock.send = jest.fn() + appMock.debug = { + log: jest.fn(), + error: jest.fn(), + } + appMock.session = { + getSessionToken: jest.fn().mockReturnValue('token'), + } + appMock.options = { + ingestPoint: 'http://example.com', + } + + canvasRecorder = new CanvasRecorder(appMock, { fps: 30, quality: 'medium' }) + nodeMock = document.createElement('canvas') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('startTracking', () => { + test('scans tree and attaches node callback after timeout', () => { + jest.useFakeTimers() + canvasRecorder.startTracking() + jest.advanceTimersByTime(500) + expect(appMock.nodes.scanTree).toHaveBeenCalled() + expect(appMock.nodes.attachNodeCallback).toHaveBeenCalled() + jest.useRealTimers() + }) + }) + + describe('restartTracking', () => { + test('clears previous intervals and rescans the tree', () => { + const clearSpy = jest.spyOn(canvasRecorder, 'clear') + canvasRecorder.restartTracking() + expect(clearSpy).toHaveBeenCalled() + expect(appMock.nodes.scanTree).toHaveBeenCalled() + }) + }) + + describe('captureCanvas', () => { + test('captures canvas and starts observing it', () => { + const observeMock = jest.fn() + window.IntersectionObserver = jest.fn().mockImplementation((callback) => { + return { + observe: observeMock, + disconnect: jest.fn(), + } + }) + + canvasRecorder.captureCanvas(nodeMock) + expect(observeMock).toHaveBeenCalledWith(nodeMock) + }) + + test('does not capture canvas if it is obscured', () => { + appMock.sanitizer.isObscured.mockReturnValue(true) + const observeMock = jest.fn() + window.IntersectionObserver = jest.fn().mockImplementation((callback) => { + return { + observe: observeMock, + disconnect: jest.fn(), + } + }) + + canvasRecorder.captureCanvas(nodeMock) + expect(observeMock).not.toHaveBeenCalled() + }) + }) + + describe('recordCanvas', () => { + test('records canvas and sends snapshots at intervals', () => { + jest.useFakeTimers() + canvasRecorder.recordCanvas(nodeMock, 1) + jest.advanceTimersByTime(1000 / 30) + expect(appMock.send).toHaveBeenCalled() + jest.useRealTimers() + }) + }) + + describe('clear', () => { + test('clears intervals and snapshots', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval') + canvasRecorder.recordCanvas(nodeMock, 1) + canvasRecorder.clear() + expect(clearIntervalSpy).toHaveBeenCalled() + expect(canvasRecorder['snapshots']).toEqual({}) + }) + }) +}) diff --git a/tracker/tracker/src/tests/conditionsManager.test.ts b/tracker/tracker/src/tests/conditionsManager.test.ts index 4e7117d53..2ba93cb29 100644 --- a/tracker/tracker/src/tests/conditionsManager.test.ts +++ b/tracker/tracker/src/tests/conditionsManager.test.ts @@ -5,8 +5,8 @@ import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globa const Type = { JSException: 78, CustomEvent: 27, - MouseClick: 69, - SetPageLocation: 4, + MouseClick: 68, + SetPageLocation: 122, NetworkRequest: 83, } @@ -142,6 +142,7 @@ describe('ConditionsManager', () => { 'https://example.com', 'referrer', Date.now(), + 'this is a test title', ] manager.processMessage(setPageLocationMessage) expect(manager.hasStarted).toBeTruthy() diff --git a/tracker/tracker/src/tests/connection.test.ts b/tracker/tracker/src/tests/connection.test.ts new file mode 100644 index 000000000..403084a64 --- /dev/null +++ b/tracker/tracker/src/tests/connection.test.ts @@ -0,0 +1,42 @@ +import connection from '../main/modules/connection' +import { describe, beforeEach, afterEach, it, expect, jest } from '@jest/globals' + +describe('Connection module', () => { + const appMock = { + send: jest.fn(), + } + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should send connection information', () => { + const connectionInfo = { + downlink: 1, + type: 'wifi', + addEventListener: jest.fn(), + } + Object.assign(navigator, { connection: connectionInfo }) + // @ts-ignore + connection(appMock) + expect(appMock.send).toHaveBeenCalledWith([54, 1000, 'wifi']) + }) + + it('should send unknown connection type', () => { + const connectionInfo = { + downlink: 1, + addEventListener: jest.fn(), + } + Object.assign(navigator, { connection: connectionInfo }) + // @ts-ignore + connection(appMock) + expect(appMock.send).toHaveBeenCalledWith([54, 1000, 'unknown']) + }) + + it('should not send connection information if connection is undefined', () => { + Object.assign(navigator, { connection: undefined }) + // @ts-ignore + connection(appMock) + expect(appMock.send).not.toHaveBeenCalled() + }) +}) diff --git a/tracker/tracker/src/tests/utils.unit.test.ts b/tracker/tracker/src/tests/utils.unit.test.ts index 19d83418b..b1c8cc7ee 100644 --- a/tracker/tracker/src/tests/utils.unit.test.ts +++ b/tracker/tracker/src/tests/utils.unit.test.ts @@ -13,6 +13,7 @@ import { generateRandomId, ngSafeBrowserMethod, requestIdleCb, + inIframe, } from '../main/utils.js' describe('adjustTimeOrigin', () => { @@ -185,6 +186,15 @@ describe('generateRandomId', () => { expect(id).toHaveLength(40) expect(/^[0-9a-f]+$/.test(id)).toBe(true) }) + + test('falls back to Math.random if crypto api is not available', () => { + const originalCrypto = window.crypto + // @ts-ignore + window.crypto = undefined + const id = generateRandomId(20) + expect(id).toHaveLength(20) + expect(/^[0-9a-f]+$/.test(id)).toBe(true) + }) }) describe('ngSafeBrowserMethod', () => { @@ -207,7 +217,7 @@ describe('requestIdleCb', () => { test('testing FIFO scheduler', async () => { jest.useFakeTimers() // @ts-ignore - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()) const cb1 = jest.fn() const cb2 = jest.fn() @@ -221,3 +231,18 @@ describe('requestIdleCb', () => { expect(cb2).toBeCalledTimes(1) }) }) + +describe('inIframe', () => { + test('returns true if the code is running inside an iframe', () => { + const originalSelf = window.self + const originalTop = window.top + + Object.defineProperty(window, 'self', { value: {} }) + Object.defineProperty(window, 'top', { value: {} }) + + expect(inIframe()).toBe(true) + + Object.defineProperty(window, 'self', { value: originalSelf }) + Object.defineProperty(window, 'top', { value: originalTop }) + }) +}) diff --git a/tracker/tracker/src/webworker/MessageEncoder.gen.ts b/tracker/tracker/src/webworker/MessageEncoder.gen.ts index b7f10c2c2..e1d294fac 100644 --- a/tracker/tracker/src/webworker/MessageEncoder.gen.ts +++ b/tracker/tracker/src/webworker/MessageEncoder.gen.ts @@ -14,7 +14,7 @@ export default class MessageEncoder extends PrimitiveEncoder { return this.uint(msg[1]) break - case Messages.Type.SetPageLocation: + case Messages.Type.SetPageLocationDeprecated: return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) break @@ -211,6 +211,10 @@ export default class MessageEncoder extends PrimitiveEncoder { break case Messages.Type.MouseClick: + return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.uint(msg[5]) && this.uint(msg[6]) + break + + case Messages.Type.MouseClickDeprecated: return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) && this.string(msg[4]) break @@ -302,6 +306,10 @@ export default class MessageEncoder extends PrimitiveEncoder { return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.uint(msg[4]) break + case Messages.Type.SetPageLocation: + return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.string(msg[4]) + break + } }