From 960da9f037328c6cc039893a26993a1828a725c8 Mon Sep 17 00:00:00 2001 From: Delirium Date: Mon, 24 Jun 2024 13:49:26 +0200 Subject: [PATCH] Tracker 14.x.x changes (#2240) * feat tracker: add document titles to tabs * feat: titles for tabs * feat tracker: send initial title, parse titles better * feat ui: tab name styles * feat tracker: update changelogs * fix tracker: fix tests * fix tracker: fix failing tests, add some coverage * fix tracker: fix failing tests, add some coverage * Heatmaps (#2264) * feat ui: start heatmaps ui and tracker update * fix ui: drop clickmap from session * fix ui: refactor heatmap painter * fix ui: store click coords as int percent * feat(backend): insert normalized x and y to PG * feat(backend): insert normalized x and y to CH * feat(connector): added missing import * feat(backend): fixed different uint type issue * fix tracker: use max scrollable size for doc * fix gen files * fix ui: fix random crash, remove demo data generator * fix ui: rm some dead code --------- Co-authored-by: Alexander * fix tracker: add heatmap changelog to tracker CHANGELOG.md * fix tracker: fix peerjs version to 1.5.4 (was 1.5.1) * fix document height calculation * Crossdomain tracking (#2277) * feat tracker: crossdomain tracking (start commit) * catch crossdomain messages, add nodeid placeholder * click scroll * frame placeholder number -> dynamic * click rewriter, fix scroll prop * some docs * some docs * fix options merging * CHANGELOG.md update * checking that crossdomain will not fire automatically * simplify func declaration * update test data * change clickmap document height calculation to scrollheight (which should be true height) --------- Co-authored-by: Alexander --- backend/pkg/db/postgres/bulks.go | 14 +- backend/pkg/db/postgres/events.go | 13 +- backend/pkg/messages/filters.go | 2 +- .../pkg/messages/legacy-message-transform.go | 9 + backend/pkg/messages/messages.go | 76 +- backend/pkg/messages/read-message.go | 54 +- ee/backend/pkg/db/clickhouse/connector.go | 12 +- ee/connectors/msgcodec/messages.py | 62 +- ee/connectors/msgcodec/messages.pyx | 74 +- ee/connectors/msgcodec/msgcodec.py | 60 +- ee/connectors/msgcodec/msgcodec.pyx | 60 +- .../ClickMapCard/ClickMapCard.tsx | 35 +- .../Player/ClickMapRenderer/ThinPlayer.tsx | 38 +- .../Player/MobilePlayer/PlayerContent.tsx | 1 - .../Player/ReplayPlayer/AudioPlayer.tsx | 1 - .../Player/SharedComponents/SessionTabs.tsx | 3 +- .../Session/Player/SharedComponents/Tab.tsx | 31 +- frontend/app/components/Session/WebPlayer.tsx | 1 - frontend/app/duck/sessions.ts | 1 + frontend/app/player/web/MessageLoader.ts | 10 +- frontend/app/player/web/MessageManager.ts | 18 +- frontend/app/player/web/Screen/Screen.ts | 4 +- frontend/app/player/web/TabManager.ts | 11 +- .../app/player/web/addons/TargetMarker.ts | 314 +++-- .../app/player/web/addons/simpleHeatmap.ts | 162 +++ .../web/messages/RawMessageReader.gen.ts | 56 +- .../app/player/web/messages/filters.gen.ts | 2 +- .../app/player/web/messages/message.gen.ts | 50 +- frontend/app/player/web/messages/raw.gen.ts | 90 +- .../player/web/messages/tracker-legacy.gen.ts | 26 +- .../app/player/web/messages/tracker.gen.ts | 48 +- mobs/messages.rb | 24 +- tracker/tracker-assist/bun.lockb | Bin 233679 -> 229402 bytes tracker/tracker-assist/package.json | 2 +- tracker/tracker/CHANGELOG.md | 6 + tracker/tracker/package.json | 2 +- tracker/tracker/src/common/messages.gen.ts | 30 +- tracker/tracker/src/main/app/index.ts | 1019 ++++++++++------- tracker/tracker/src/main/app/messages.gen.ts | 40 +- tracker/tracker/src/main/app/nodes.ts | 17 +- .../src/main/app/observer/iframe_observer.ts | 10 + .../src/main/app/observer/iframe_offsets.ts | 2 - .../src/main/app/observer/top_observer.ts | 15 + tracker/tracker/src/main/index.ts | 31 +- tracker/tracker/src/main/modules/mouse.ts | 21 +- tracker/tracker/src/main/modules/scroll.ts | 15 +- tracker/tracker/src/main/modules/viewport.ts | 6 +- tracker/tracker/src/main/utils.ts | 43 +- tracker/tracker/src/tests/canvas.test.ts | 113 ++ .../src/tests/conditionsManager.test.ts | 5 +- tracker/tracker/src/tests/connection.test.ts | 42 + tracker/tracker/src/tests/utils.unit.test.ts | 27 +- .../src/webworker/MessageEncoder.gen.ts | 10 +- 53 files changed, 1944 insertions(+), 874 deletions(-) create mode 100644 frontend/app/player/web/addons/simpleHeatmap.ts create mode 100644 tracker/tracker/src/tests/canvas.test.ts create mode 100644 tracker/tracker/src/tests/connection.test.ts 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 b218a8969a6a0af3e1393cbcf92443352fc156ac..c1bf88d0dfa0a31ed34361c4f72a26dfb8ef5c82 100755 GIT binary patch delta 43891 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 frxu0W!2%D-RqS#Or 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 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 + } }