diff --git a/backend/cmd/db/main.go b/backend/cmd/db/main.go index ae1228cc3..6086d9491 100644 --- a/backend/cmd/db/main.go +++ b/backend/cmd/db/main.go @@ -38,7 +38,7 @@ func main() { messages.MsgFetch, messages.MsgNetworkRequest, messages.MsgGraphQL, messages.MsgStateAction, messages.MsgSetInputTarget, messages.MsgSetInputValue, messages.MsgCreateDocument, messages.MsgMouseClick, messages.MsgSetPageLocation, messages.MsgPageLoadTiming, messages.MsgPageRenderTiming, - messages.MsgInputEvent, messages.MsgPageEvent} + messages.MsgInputEvent, messages.MsgPageEvent, messages.MsgMouseThrashing, messages.MsgInputChange} // Init consumer consumer := queue.NewConsumer( diff --git a/backend/internal/db/datasaver/saver.go b/backend/internal/db/datasaver/saver.go index 92dbff958..1a017fa6f 100644 --- a/backend/internal/db/datasaver/saver.go +++ b/backend/internal/db/datasaver/saver.go @@ -77,6 +77,10 @@ func (s *saverImpl) handleMessage(msg Message) error { return s.pg.InsertWebJSException(m) case *IntegrationEvent: return s.pg.InsertWebIntegrationEvent(m) + case *InputChange: + return s.pg.InsertWebInputDuration(m) + case *MouseThrashing: + return s.pg.InsertMouseThrashing(m) case *IOSSessionStart: return s.pg.InsertIOSSessionStart(m) case *IOSSessionEnd: diff --git a/backend/pkg/db/cache/messages-web.go b/backend/pkg/db/cache/messages-web.go index 0a870e5a2..58c703318 100644 --- a/backend/pkg/db/cache/messages-web.go +++ b/backend/pkg/db/cache/messages-web.go @@ -180,3 +180,21 @@ func (c *PGCache) InsertWebInputEvent(e *InputEvent) error { } return c.Conn.InsertWebInputEvent(sessionID, session.ProjectID, e) } + +func (c *PGCache) InsertWebInputDuration(e *InputChange) error { + sessionID := e.SessionID() + session, err := c.Cache.GetSession(sessionID) + if err != nil { + return err + } + return c.Conn.InsertWebInputDuration(sessionID, session.ProjectID, e) +} + +func (c *PGCache) InsertMouseThrashing(e *MouseThrashing) error { + sessionID := e.SessionID() + session, err := c.Cache.GetSession(sessionID) + if err != nil { + return err + } + return c.Conn.InsertMouseThrashing(sessionID, session.ProjectID, e) +} diff --git a/backend/pkg/db/postgres/bulks.go b/backend/pkg/db/postgres/bulks.go index 27ab2cafd..7eaba41b4 100644 --- a/backend/pkg/db/postgres/bulks.go +++ b/backend/pkg/db/postgres/bulks.go @@ -9,7 +9,7 @@ type bulksTask struct { } func NewBulksTask() *bulksTask { - return &bulksTask{bulks: make([]Bulk, 0, 14)} + return &bulksTask{bulks: make([]Bulk, 0, 15)} } type BulkSet struct { @@ -19,6 +19,7 @@ type BulkSet struct { customEvents Bulk webPageEvents Bulk webInputEvents Bulk + webInputDurations Bulk webGraphQL Bulk webErrors Bulk webErrorEvents Bulk @@ -57,6 +58,8 @@ func (conn *BulkSet) Get(name string) Bulk { return conn.webPageEvents case "webInputEvents": return conn.webInputEvents + case "webInputDurations": + return conn.webInputDurations case "webGraphQL": return conn.webGraphQL case "webErrors": @@ -126,6 +129,14 @@ func (conn *BulkSet) initBulks() { if err != nil { log.Fatalf("can't create webPageEvents bulk: %s", err) } + conn.webInputDurations, err = NewBulk(conn.c, + "events.inputs", + "(session_id, message_id, timestamp, value, label, hesitation, duration)", + "($%d, $%d, $%d, LEFT($%d, 2000), NULLIF(LEFT($%d, 2000),''), $%d, $%d)", + 7, 200) + if err != nil { + log.Fatalf("can't create webPageEvents bulk: %s", err) + } conn.webGraphQL, err = NewBulk(conn.c, "events.graphql", "(session_id, timestamp, message_id, name, request_body, response_body)", @@ -184,9 +195,9 @@ func (conn *BulkSet) initBulks() { } conn.webClickEvents, err = NewBulk(conn.c, "events.clicks", - "(session_id, message_id, timestamp, label, selector, url, path)", - "($%d, $%d, $%d, NULLIF(LEFT($%d, 2000), ''), LEFT($%d, 8000), LEFT($%d, 2000), LEFT($%d, 2000))", - 7, 200) + "(session_id, message_id, timestamp, label, selector, url, path, hesitation)", + "($%d, $%d, $%d, NULLIF(LEFT($%d, 2000), ''), LEFT($%d, 8000), LEFT($%d, 2000), LEFT($%d, 2000), $%d)", + 8, 200) if err != nil { log.Fatalf("can't create webClickEvents bulk: %s", err) } @@ -209,6 +220,7 @@ func (conn *BulkSet) Send() { newTask.bulks = append(newTask.bulks, conn.customEvents) newTask.bulks = append(newTask.bulks, conn.webPageEvents) newTask.bulks = append(newTask.bulks, conn.webInputEvents) + newTask.bulks = append(newTask.bulks, conn.webInputDurations) newTask.bulks = append(newTask.bulks, conn.webGraphQL) newTask.bulks = append(newTask.bulks, conn.webErrors) newTask.bulks = append(newTask.bulks, conn.webErrorEvents) diff --git a/backend/pkg/db/postgres/messages-web.go b/backend/pkg/db/postgres/messages-web.go index 9251a4924..ebba00168 100644 --- a/backend/pkg/db/postgres/messages-web.go +++ b/backend/pkg/db/postgres/messages-web.go @@ -2,8 +2,8 @@ package postgres import ( "log" - "openreplay/backend/pkg/db/types" + "openreplay/backend/pkg/hashid" . "openreplay/backend/pkg/messages" "openreplay/backend/pkg/url" ) @@ -63,7 +63,7 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *Mou } var host, path string host, path, _, _ = url.GetURLParts(e.Url) - if err := conn.bulks.Get("webClickEvents").Append(sessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path); err != nil { + if err := conn.bulks.Get("webClickEvents").Append(sessionID, truncSqIdx(e.MsgID()), e.Timestamp, e.Label, e.Selector, host+path, path, e.HesitationTime); err != nil { log.Printf("insert web click err: %s", err) } // Accumulate session updates and exec inside batch with another sql commands @@ -89,6 +89,24 @@ func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *Inp return nil } +func (conn *Conn) InsertWebInputDuration(sessionID uint64, projectID uint32, e *InputChange) error { + // Debug log + log.Printf("new InputDuration event: %v", e) + if e.Label == "" { + return nil + } + value := &e.Value + if e.ValueMasked { + value = nil + } + if err := conn.bulks.Get("webInputDurations").Append(sessionID, truncSqIdx(e.ID), e.Timestamp, value, e.Label, e.HesitationTime, e.InputDuration); err != nil { + log.Printf("insert web input event err: %s", err) + } + conn.updateSessionEvents(sessionID, 1, 0) + conn.insertAutocompleteValue(sessionID, projectID, "INPUT", e.Label) + return nil +} + func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *types.ErrorEvent) error { errorID := e.ID(projectID) if err := conn.bulks.Get("webErrors").Append(errorID, projectID, e.Source, e.Name, e.Message, e.Payload); err != nil { @@ -145,3 +163,18 @@ func (conn *Conn) InsertSessionReferrer(sessionID uint64, referrer string) error WHERE session_id = $3 AND referrer IS NULL`, referrer, url.DiscardURLQuery(referrer), sessionID) } + +func (conn *Conn) InsertMouseThrashing(sessionID uint64, projectID uint32, e *MouseThrashing) error { + // Debug log + log.Printf("new MouseThrashing event: %v", e) + // + issueID := hashid.MouseThrashingID(projectID, sessionID, e.Timestamp) + if err := conn.bulks.Get("webIssues").Append(projectID, issueID, "mouse_thrashing", e.Url); err != nil { + log.Printf("insert web issue err: %s", err) + } + if err := conn.bulks.Get("webIssueEvents").Append(sessionID, issueID, e.Timestamp, truncSqIdx(e.MsgID()), nil); err != nil { + log.Printf("insert web issue event err: %s", err) + } + conn.updateSessionIssues(sessionID, 0, 50) + return nil +} diff --git a/backend/pkg/hashid/hashid.go b/backend/pkg/hashid/hashid.go index 25ce11369..5bcb23578 100644 --- a/backend/pkg/hashid/hashid.go +++ b/backend/pkg/hashid/hashid.go @@ -23,3 +23,11 @@ func IOSCrashID(projectID uint32, crash *messages.IOSCrash) string { hash.Write([]byte(crash.Stacktrace)) return strconv.FormatUint(uint64(projectID), 16) + hex.EncodeToString(hash.Sum(nil)) } + +func MouseThrashingID(projectID uint32, sessID, ts uint64) string { + hash := fnv.New128a() + hash.Write([]byte("mouse_trashing")) + hash.Write([]byte(strconv.FormatUint(sessID, 10))) + hash.Write([]byte(strconv.FormatUint(ts, 10))) + return strconv.FormatUint(uint64(projectID), 16) + hex.EncodeToString(hash.Sum(nil)) +} diff --git a/backend/pkg/messages/filters.go b/backend/pkg/messages/filters.go index 300e38883..c1a28eb55 100644 --- a/backend/pkg/messages/filters.go +++ b/backend/pkg/messages/filters.go @@ -2,7 +2,7 @@ package messages func IsReplayerType(id int) bool { - return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 42 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 125 != id && 126 != id && 127 != id && 107 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 99 != id && 101 != id && 104 != id && 110 != id && 111 != id + return 1 != id && 3 != id && 17 != id && 23 != id && 24 != id && 25 != id && 26 != id && 27 != id && 28 != id && 29 != id && 30 != id && 31 != id && 32 != id && 33 != id && 35 != id && 42 != id && 52 != id && 56 != id && 62 != id && 63 != id && 64 != id && 66 != id && 78 != id && 80 != id && 81 != id && 82 != id && 125 != id && 126 != id && 127 != id && 112 != id && 107 != id && 91 != id && 92 != id && 94 != id && 95 != id && 97 != id && 98 != id && 99 != id && 101 != id && 104 != id && 110 != id && 111 != id } func IsIOSType(id int) bool { @@ -10,5 +10,6 @@ func IsIOSType(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 || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id -} \ No newline at end of file + 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 || 90 == id || 93 == id || 96 == id || 100 == id || 102 == id || 103 == id || 105 == id +} + diff --git a/backend/pkg/messages/messages.go b/backend/pkg/messages/messages.go index 52fbb7a64..bff0daea0 100644 --- a/backend/pkg/messages/messages.go +++ b/backend/pkg/messages/messages.go @@ -2,102 +2,108 @@ package messages const ( - MsgTimestamp = 0 - MsgSessionStart = 1 - MsgSessionEndDeprecated = 3 - MsgSetPageLocation = 4 - MsgSetViewportSize = 5 - MsgSetViewportScroll = 6 - MsgCreateDocument = 7 - MsgCreateElementNode = 8 - MsgCreateTextNode = 9 - MsgMoveNode = 10 - MsgRemoveNode = 11 - MsgSetNodeAttribute = 12 - MsgRemoveNodeAttribute = 13 - MsgSetNodeData = 14 - MsgSetCSSData = 15 - MsgSetNodeScroll = 16 - MsgSetInputTarget = 17 - MsgSetInputValue = 18 - MsgSetInputChecked = 19 - MsgMouseMove = 20 - MsgNetworkRequest = 21 - MsgConsoleLog = 22 - MsgPageLoadTiming = 23 - MsgPageRenderTiming = 24 - MsgJSExceptionDeprecated = 25 - MsgIntegrationEvent = 26 - MsgCustomEvent = 27 - MsgUserID = 28 - MsgUserAnonymousID = 29 - MsgMetadata = 30 - MsgPageEvent = 31 - MsgInputEvent = 32 - MsgCSSInsertRule = 37 - MsgCSSDeleteRule = 38 - MsgFetch = 39 - MsgProfiler = 40 - MsgOTable = 41 - MsgStateAction = 42 - MsgRedux = 44 - MsgVuex = 45 - MsgMobX = 46 - MsgNgRx = 47 - MsgGraphQL = 48 - MsgPerformanceTrack = 49 - MsgStringDict = 50 - MsgSetNodeAttributeDict = 51 - MsgResourceTiming = 53 - MsgConnectionInformation = 54 - MsgSetPageVisibility = 55 - MsgPerformanceTrackAggr = 56 - MsgLoadFontFace = 57 - MsgSetNodeFocus = 58 - MsgLongTask = 59 - MsgSetNodeAttributeURLBased = 60 - MsgSetCSSDataURLBased = 61 - MsgIssueEventDeprecated = 62 - MsgTechnicalInfo = 63 - MsgCustomIssue = 64 - MsgAssetCache = 66 - MsgCSSInsertRuleURLBased = 67 - MsgMouseClick = 69 - MsgCreateIFrameDocument = 70 - MsgAdoptedSSReplaceURLBased = 71 - MsgAdoptedSSReplace = 72 - MsgAdoptedSSInsertRuleURLBased = 73 - MsgAdoptedSSInsertRule = 74 - MsgAdoptedSSDeleteRule = 75 - MsgAdoptedSSAddOwner = 76 - MsgAdoptedSSRemoveOwner = 77 - MsgJSException = 78 - MsgZustand = 79 - MsgBatchMeta = 80 - MsgBatchMetadata = 81 - MsgPartitionedMessage = 82 - MsgIssueEvent = 125 - MsgSessionEnd = 126 - MsgSessionSearch = 127 - MsgIOSBatchMeta = 107 - MsgIOSSessionStart = 90 - MsgIOSSessionEnd = 91 - MsgIOSMetadata = 92 - MsgIOSCustomEvent = 93 - MsgIOSUserID = 94 - MsgIOSUserAnonymousID = 95 - MsgIOSScreenChanges = 96 - MsgIOSCrash = 97 - MsgIOSScreenEnter = 98 - MsgIOSScreenLeave = 99 - MsgIOSClickEvent = 100 - MsgIOSInputEvent = 101 - MsgIOSPerformanceEvent = 102 - MsgIOSLog = 103 - MsgIOSInternalError = 104 - MsgIOSNetworkCall = 105 - MsgIOSPerformanceAggregated = 110 - MsgIOSIssueEvent = 111 + MsgTimestamp = 0 + MsgSessionStart = 1 + MsgSessionEndDeprecated = 3 + MsgSetPageLocation = 4 + MsgSetViewportSize = 5 + MsgSetViewportScroll = 6 + MsgCreateDocument = 7 + MsgCreateElementNode = 8 + MsgCreateTextNode = 9 + MsgMoveNode = 10 + MsgRemoveNode = 11 + MsgSetNodeAttribute = 12 + MsgRemoveNodeAttribute = 13 + MsgSetNodeData = 14 + MsgSetCSSData = 15 + MsgSetNodeScroll = 16 + MsgSetInputTarget = 17 + MsgSetInputValue = 18 + MsgSetInputChecked = 19 + MsgMouseMove = 20 + MsgNetworkRequest = 21 + MsgConsoleLog = 22 + MsgPageLoadTiming = 23 + MsgPageRenderTiming = 24 + MsgJSExceptionDeprecated = 25 + MsgIntegrationEvent = 26 + MsgCustomEvent = 27 + MsgUserID = 28 + MsgUserAnonymousID = 29 + MsgMetadata = 30 + MsgPageEvent = 31 + MsgInputEvent = 32 + MsgClickEvent = 33 + MsgResourceEvent = 35 + MsgCSSInsertRule = 37 + MsgCSSDeleteRule = 38 + MsgFetch = 39 + MsgProfiler = 40 + MsgOTable = 41 + MsgStateAction = 42 + MsgRedux = 44 + MsgVuex = 45 + MsgMobX = 46 + MsgNgRx = 47 + MsgGraphQL = 48 + MsgPerformanceTrack = 49 + MsgStringDict = 50 + MsgSetNodeAttributeDict = 51 + MsgDOMDrop = 52 + MsgResourceTiming = 53 + MsgConnectionInformation = 54 + MsgSetPageVisibility = 55 + MsgPerformanceTrackAggr = 56 + MsgLoadFontFace = 57 + MsgSetNodeFocus = 58 + MsgLongTask = 59 + MsgSetNodeAttributeURLBased = 60 + MsgSetCSSDataURLBased = 61 + MsgIssueEventDeprecated = 62 + MsgTechnicalInfo = 63 + MsgCustomIssue = 64 + MsgAssetCache = 66 + MsgCSSInsertRuleURLBased = 67 + MsgMouseClick = 69 + MsgCreateIFrameDocument = 70 + MsgAdoptedSSReplaceURLBased = 71 + MsgAdoptedSSReplace = 72 + MsgAdoptedSSInsertRuleURLBased = 73 + MsgAdoptedSSInsertRule = 74 + MsgAdoptedSSDeleteRule = 75 + MsgAdoptedSSAddOwner = 76 + MsgAdoptedSSRemoveOwner = 77 + MsgJSException = 78 + MsgZustand = 79 + MsgBatchMeta = 80 + MsgBatchMetadata = 81 + MsgPartitionedMessage = 82 + MsgIssueEvent = 125 + MsgSessionEnd = 126 + MsgSessionSearch = 127 + MsgInputChange = 112 + MsgSelectionChange = 113 + MsgMouseThrashing = 114 + MsgIOSBatchMeta = 107 + MsgIOSSessionStart = 90 + MsgIOSSessionEnd = 91 + MsgIOSMetadata = 92 + MsgIOSCustomEvent = 93 + MsgIOSUserID = 94 + MsgIOSUserAnonymousID = 95 + MsgIOSScreenChanges = 96 + MsgIOSCrash = 97 + MsgIOSScreenEnter = 98 + MsgIOSScreenLeave = 99 + MsgIOSClickEvent = 100 + MsgIOSInputEvent = 101 + MsgIOSPerformanceEvent = 102 + MsgIOSLog = 103 + MsgIOSInternalError = 104 + MsgIOSNetworkCall = 105 + MsgIOSPerformanceAggregated = 110 + MsgIOSIssueEvent = 111 ) @@ -2102,6 +2108,83 @@ func (msg *SessionSearch) TypeID() int { return 127 } +type InputChange struct { + message + ID uint64 + Value string + ValueMasked bool + Label string + HesitationTime int64 + InputDuration int64 +} + +func (msg *InputChange) Encode() []byte { + buf := make([]byte, 61+len(msg.Value)+len(msg.Label)) + buf[0] = 112 + p := 1 + p = WriteUint(msg.ID, buf, p) + p = WriteString(msg.Value, buf, p) + p = WriteBoolean(msg.ValueMasked, buf, p) + p = WriteString(msg.Label, buf, p) + p = WriteInt(msg.HesitationTime, buf, p) + p = WriteInt(msg.InputDuration, buf, p) + return buf[:p] +} + +func (msg *InputChange) Decode() Message { + return msg +} + +func (msg *InputChange) TypeID() int { + return 112 +} + +type SelectionChange struct { + message + SelectionStart uint64 + SelectionEnd uint64 + Selection string +} + +func (msg *SelectionChange) Encode() []byte { + buf := make([]byte, 31+len(msg.Selection)) + buf[0] = 113 + p := 1 + p = WriteUint(msg.SelectionStart, buf, p) + p = WriteUint(msg.SelectionEnd, buf, p) + p = WriteString(msg.Selection, buf, p) + return buf[:p] +} + +func (msg *SelectionChange) Decode() Message { + return msg +} + +func (msg *SelectionChange) TypeID() int { + return 113 +} + +type MouseThrashing struct { + message + Timestamp uint64 +} + +func (msg *MouseThrashing) Encode() []byte { + buf := make([]byte, 11) + buf[0] = 114 + p := 1 + p = WriteUint(msg.Timestamp, buf, p) + return buf[:p] +} + +func (msg *MouseThrashing) Decode() Message { + return msg +} + +func (msg *MouseThrashing) TypeID() int { + return 114 +} + type IOSBatchMeta struct { message Timestamp uint64 diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 7b0fc37ea..d227b3295 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -1272,6 +1272,54 @@ func DecodeSessionSearch(reader BytesReader) (Message, error) { return msg, err } +func DecodeInputChange(reader BytesReader) (Message, error) { + var err error = nil + msg := &InputChange{} + if msg.ID, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.Value, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.ValueMasked, err = reader.ReadBoolean(); err != nil { + return nil, err + } + if msg.Label, err = reader.ReadString(); err != nil { + return nil, err + } + if msg.HesitationTime, err = reader.ReadInt(); err != nil { + return nil, err + } + if msg.InputDuration, err = reader.ReadInt(); err != nil { + return nil, err + } + return msg, err +} + +func DecodeSelectionChange(reader BytesReader) (Message, error) { + var err error = nil + msg := &SelectionChange{} + if msg.SelectionStart, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.SelectionEnd, err = reader.ReadUint(); err != nil { + return nil, err + } + if msg.Selection, err = reader.ReadString(); err != nil { + return nil, err + } + return msg, err +} + +func DecodeMouseThrashing(reader BytesReader) (Message, error) { + var err error = nil + msg := &MouseThrashing{} + if msg.Timestamp, err = reader.ReadUint(); err != nil { + return nil, err + } + return msg, err +} + func DecodeIOSBatchMeta(reader BytesReader) (Message, error) { var err error = nil msg := &IOSBatchMeta{} @@ -1830,6 +1878,12 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) { return DecodeSessionEnd(reader) case 127: return DecodeSessionSearch(reader) + case 112: + return DecodeInputChange(reader) + case 113: + return DecodeSelectionChange(reader) + case 114: + return DecodeMouseThrashing(reader) case 107: return DecodeIOSBatchMeta(reader) case 90: diff --git a/ee/backend/internal/db/datasaver/methods.go b/ee/backend/internal/db/datasaver/methods.go index 277fd8906..ac0a8b88d 100644 --- a/ee/backend/internal/db/datasaver/methods.go +++ b/ee/backend/internal/db/datasaver/methods.go @@ -78,6 +78,10 @@ func (s *saverImpl) handleExtraMessage(msg Message) error { } case *GraphQL: return s.ch.InsertGraphQL(session, m) + case *InputChange: + return s.ch.InsertWebInputDuration(session, m) + case *MouseThrashing: + return s.ch.InsertMouseThrashing(session, m) } return nil } diff --git a/ee/backend/pkg/db/clickhouse/connector.go b/ee/backend/pkg/db/clickhouse/connector.go index 489411550..0ee0658ef 100644 --- a/ee/backend/pkg/db/clickhouse/connector.go +++ b/ee/backend/pkg/db/clickhouse/connector.go @@ -32,6 +32,8 @@ type Connector interface { InsertCustom(session *types.Session, msg *messages.CustomEvent) error InsertGraphQL(session *types.Session, msg *messages.GraphQL) error InsertIssue(session *types.Session, msg *messages.IssueEvent) error + InsertWebInputDuration(session *types.Session, msg *messages.InputChange) error + InsertMouseThrashing(session *types.Session, msg *messages.MouseThrashing) error } type task struct { @@ -97,7 +99,7 @@ var batches = map[string]string{ "autocompletes": "INSERT INTO experimental.autocomplete (project_id, type, value) VALUES (?, ?, ?)", "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, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "clicks": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, hesitation_time, event_type) VALUES (?, ?, ?, ?, ?, ?, ?)", - "inputs": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, event_type) 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), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "requests": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, request_body, response_body, status, method, duration, success, event_type) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?)", @@ -164,11 +166,59 @@ func (c *connectorImpl) checkError(name string, err error) { } } +func (c *connectorImpl) InsertWebInputDuration(session *types.Session, msg *messages.InputChange) error { + if msg.Label == "" { + return nil + } + if err := c.batches["inputs"].Append( + session.SessionID, + uint16(session.ProjectID), + msg.MsgID(), + datetime(msg.Timestamp), + msg.Label, + "INPUT", + nullableUint16(uint16(msg.InputDuration)), + nullableUint32(uint32(msg.HesitationTime)), + ); err != nil { + c.checkError("inputs", err) + return fmt.Errorf("can't append to inputs batch: %s", err) + } + return nil +} + +func (c *connectorImpl) InsertMouseThrashing(session *types.Session, msg *messages.MouseThrashing) error { + issueID := hashid.MouseThrashingID(session.ProjectID, session.SessionID, msg.Timestamp) + // Insert issue event to batches + if err := c.batches["issuesEvents"].Append( + session.SessionID, + uint16(session.ProjectID), + msg.MsgID(), + datetime(msg.Timestamp), + issueID, + "mouse_thrashing", + "ISSUE", + msg.Url, + ); err != nil { + c.checkError("issuesEvents", err) + return fmt.Errorf("can't append to issuesEvents batch: %s", err) + } + if err := c.batches["issues"].Append( + uint16(session.ProjectID), + issueID, + "mouse_thrashing", + msg.Url, + ); err != nil { + c.checkError("issues", err) + return fmt.Errorf("can't append to issues batch: %s", err) + } + return nil +} + func (c *connectorImpl) InsertIssue(session *types.Session, msg *messages.IssueEvent) error { issueID := hashid.IssueID(session.ProjectID, msg) // Check issue type before insert to avoid panic from clickhouse lib switch msg.Type { - case "click_rage", "dead_click", "excessive_scrolling", "bad_request", "missing_resource", "memory", "cpu", "slow_resource", "slow_page_load", "crash", "ml_cpu", "ml_memory", "ml_dead_click", "ml_click_rage", "ml_mouse_thrashing", "ml_excessive_scrolling", "ml_slow_resources", "custom", "js_exception": + case "click_rage", "dead_click", "excessive_scrolling", "bad_request", "missing_resource", "memory", "cpu", "slow_resource", "slow_page_load", "crash", "ml_cpu", "ml_memory", "ml_dead_click", "ml_click_rage", "ml_mouse_thrashing", "ml_excessive_scrolling", "ml_slow_resources", "custom", "js_exception", "mouse_thrashing": default: return fmt.Errorf("unknown issueType: %s", msg.Type) } @@ -323,6 +373,8 @@ func (c *connectorImpl) InsertWebInputEvent(session *types.Session, msg *message datetime(msg.Timestamp), msg.Label, "INPUT", + nil, + nil, ); err != nil { c.checkError("inputs", err) return fmt.Errorf("can't append to inputs batch: %s", err) diff --git a/ee/connectors/msgcodec/messages.py b/ee/connectors/msgcodec/messages.py index d2f684148..91fb36a63 100644 --- a/ee/connectors/msgcodec/messages.py +++ b/ee/connectors/msgcodec/messages.py @@ -737,6 +737,34 @@ class SessionSearch(Message): self.partition = partition +class InputChange(Message): + __id__ = 112 + + def __init__(self, id, value, value_masked, label, hesitation_time, input_duration): + self.id = id + self.value = value + self.value_masked = value_masked + self.label = label + self.hesitation_time = hesitation_time + self.input_duration = input_duration + + +class SelectionChange(Message): + __id__ = 113 + + def __init__(self, selection_start, selection_end, selection): + self.selection_start = selection_start + self.selection_end = selection_end + self.selection = selection + + +class MouseThrashing(Message): + __id__ = 114 + + def __init__(self, timestamp): + self.timestamp = timestamp + + class IOSBatchMeta(Message): __id__ = 107 diff --git a/ee/connectors/msgcodec/msgcodec.py b/ee/connectors/msgcodec/msgcodec.py index cd23833da..02b5b5557 100644 --- a/ee/connectors/msgcodec/msgcodec.py +++ b/ee/connectors/msgcodec/msgcodec.py @@ -653,6 +653,28 @@ class MessageCodec(Codec): partition=self.read_uint(reader) ) + if message_id == 112: + return InputChange( + id=self.read_uint(reader), + value=self.read_string(reader), + value_masked=self.read_boolean(reader), + label=self.read_string(reader), + hesitation_time=self.read_int(reader), + input_duration=self.read_int(reader) + ) + + if message_id == 113: + return SelectionChange( + selection_start=self.read_uint(reader), + selection_end=self.read_uint(reader), + selection=self.read_string(reader) + ) + + if message_id == 114: + return MouseThrashing( + timestamp=self.read_uint(reader) + ) + if message_id == 107: return IOSBatchMeta( timestamp=self.read_uint(reader), diff --git a/frontend/app/components/Session_/EventsBlock/Event.js b/frontend/app/components/Session_/EventsBlock/Event.js index e8f985aa0..a464fcc28 100644 --- a/frontend/app/components/Session_/EventsBlock/Event.js +++ b/frontend/app/components/Session_/EventsBlock/Event.js @@ -1,7 +1,7 @@ import React from 'react'; import copy from 'copy-to-clipboard'; import cn from 'classnames'; -import { Icon, TextEllipsis } from 'UI'; +import { Icon, TextEllipsis, Tooltip } from 'UI'; import { TYPES } from 'Types/session/event'; import { prorata } from 'App/utils'; import withOverlay from 'Components/hocs/withOverlay'; @@ -9,6 +9,16 @@ import LoadInfo from './LoadInfo'; import cls from './event.module.css'; import { numberWithCommas } from 'App/utils'; +function isFrustrationEvent(evt) { + if (evt.type === 'mouse_thrashing' || evt.type === TYPES.CLICKRAGE) { + return true; + } + if (evt.type === TYPES.CLICK || evt.type === TYPES.INPUT) { + return evt.hesitation > 1000 + } + return false +} + @withOverlay() export default class Event extends React.PureComponent { state = { @@ -44,35 +54,50 @@ export default class Event extends React.PureComponent { const { event } = this.props; let title = event.type; let body; + let icon; + const isFrustration = isFrustrationEvent(event); + const tooltip = { disabled: true, text: '' } + switch (event.type) { case TYPES.LOCATION: title = 'Visited'; body = event.url; + icon = 'location'; break; case TYPES.CLICK: title = 'Clicked'; body = event.label; + icon = isFrustration ? 'click_hesitation' : 'click'; + isFrustration ? Object.assign(tooltip, { disabled: false, text: `User hesitated to click for ${Math.round(event.hesitation/1000)}s`, }) : null; break; case TYPES.INPUT: title = 'Input'; body = event.value; + icon = isFrustration ? 'input_hesitation' : 'input'; + isFrustration ? Object.assign(tooltip, { disabled: false, text: `User hesitated to enter a value for ${Math.round(event.hesitation/1000)}s`, }) : null; break; case TYPES.CLICKRAGE: title = `${ event.count } Clicks`; body = event.label; + icon = 'clickrage' break; case TYPES.IOS_VIEW: title = 'View'; body = event.name; + icon = 'ios_view' + break; + case 'mouse_thrashing': + title = 'Mouse Thrashing'; + icon = 'mouse_thrashing' break; } const isLocation = event.type === TYPES.LOCATION; - const isClickrage = event.type === TYPES.CLICKRAGE; return ( +
- { event.type && } + { event.type && }
@@ -100,6 +125,7 @@ export default class Event extends React.PureComponent {
}
+ ); }; @@ -110,17 +136,15 @@ export default class Event extends React.PureComponent { isCurrent, onClick, showSelection, - onCheckboxClick, showLoadInfo, toggleLoadInfo, isRed, - extended, - highlight = false, presentInSearch = false, - isLastInGroup, whiteBg, } = this.props; const { menuOpen } = this.state; + + const isFrustration = isFrustrationEvent(event); return (
{ this.wrapper = ref } } @@ -135,7 +159,7 @@ export default class Event extends React.PureComponent { [ cls.red ]: isRed, [ cls.clickType ]: event.type === TYPES.CLICK, [ cls.inputType ]: event.type === TYPES.INPUT, - [ cls.clickrageType ]: event.type === TYPES.CLICKRAGE, + [ cls.frustration ]: isFrustration, [ cls.highlight ] : presentInSearch, [ cls.lastInGroup ]: whiteBg, }) } @@ -146,13 +170,10 @@ export default class Event extends React.PureComponent { { event.target ? 'Copy CSS' : 'Copy URL' } } -
-
+
+
{ this.renderBody() }
- {/* { event.type === TYPES.LOCATION && -
{event.url}
- } */}
{ event.type === TYPES.LOCATION && (event.fcpTime || event.visuallyComplete || event.timeToInteractive) && [] }) { performanceChartData, stackList: stackEventList, eventList: eventsList, + frustrationsList, exceptionsList, resourceList: resourceListUnmap, fetchList, @@ -46,8 +46,8 @@ function OverviewPanel({ issuesList }: { issuesList: Record[] }) { NETWORK: resourceList, ERRORS: exceptionsList, EVENTS: stackEventList, - CLICKRAGE: eventsList.filter((item: any) => item.type === TYPES.CLICKRAGE), PERFORMANCE: performanceChartData, + FRUSTRATIONS: frustrationsList, }; }, [dataLoaded]); diff --git a/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx b/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx index 8d76a3070..3a841d97c 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/FeatureSelection/FeatureSelection.tsx @@ -4,15 +4,15 @@ import { Checkbox, Tooltip } from 'UI'; const NETWORK = 'NETWORK'; const ERRORS = 'ERRORS'; const EVENTS = 'EVENTS'; -const CLICKRAGE = 'CLICKRAGE'; +const FRUSTRATIONS = 'FRUSTRATIONS'; const PERFORMANCE = 'PERFORMANCE'; export const HELP_MESSAGE: any = { NETWORK: 'Network requests made in this session', EVENTS: 'Visualizes the events that takes place in the DOM', ERRORS: 'Visualizes native JS errors like Type, URI, Syntax etc.', - CLICKRAGE: 'Indicates user frustration when repeated clicks are recorded', PERFORMANCE: 'Summary of this session’s memory, and CPU consumption on the timeline', + FRUSTRATIONS: 'Indicates user frustrations in the session', }; interface Props { @@ -21,7 +21,7 @@ interface Props { } function FeatureSelection(props: Props) { const { list } = props; - const features = [NETWORK, ERRORS, EVENTS, CLICKRAGE, PERFORMANCE]; + const features = [NETWORK, ERRORS, EVENTS, PERFORMANCE, FRUSTRATIONS]; const disabled = list.length >= 5; return ( @@ -30,7 +30,7 @@ function FeatureSelection(props: Props) { const checked = list.includes(feature); const _disabled = disabled && !checked; return ( - + { ); }; - const renderClickRageElement = (item: any) => { + const renderFrustrationElement = (item: any) => { + const elData = { name: '', icon: ''} + if (item.type === TYPES.CLICK) Object.assign(elData, { name: `User hesitated to click for ${Math.round(item.hesitation/1000)}s`, icon: 'click-hesitation' }) + if (item.type === TYPES.INPUT) Object.assign(elData, { name: `User hesitated to enter a value for ${Math.round(item.hesitation/1000)}s`, icon: 'input-hesitation' }) + if (item.type === TYPES.CLICKRAGE) Object.assign(elData, { name: 'Click Rage', icon: 'click-rage' }) + if (item.type === issueTypes.MOUSE_THRASHING) Object.assign(elData, { name: 'Mouse Thrashing', icon: 'cursor-trash' }) + return ( - {'Click Rage'} + {elData.name}
} delay={0} placement="top" >
- +
); @@ -158,8 +166,8 @@ const TimelinePointer = React.memo((props: Props) => { if (type === 'NETWORK') { return renderNetworkElement(pointer); } - if (type === 'CLICKRAGE') { - return renderClickRageElement(pointer); + if (type === 'FRUSTRATIONS') { + return renderFrustrationElement(pointer); } if (type === 'ERRORS') { return renderExceptionElement(pointer); diff --git a/frontend/app/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index 66af7be76..eeaeae5dd 100644 --- a/frontend/app/components/ui/SVG.tsx +++ b/frontend/app/components/ui/SVG.tsx @@ -1,7 +1,7 @@ import React from 'react'; -export type IconNames = 'activity' | 'alarm-clock' | 'alarm-plus' | 'all-sessions' | 'analytics' | 'anchor' | 'arrow-alt-square-right' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-square-left' | 'arrow-square-right' | 'arrow-up-short' | 'arrow-up' | 'arrows-angle-extend' | 'avatar/icn_bear' | 'avatar/icn_beaver' | 'avatar/icn_bird' | 'avatar/icn_bison' | 'avatar/icn_camel' | 'avatar/icn_chameleon' | 'avatar/icn_deer' | 'avatar/icn_dog' | 'avatar/icn_dolphin' | 'avatar/icn_elephant' | 'avatar/icn_fish' | 'avatar/icn_fox' | 'avatar/icn_gorilla' | 'avatar/icn_hippo' | 'avatar/icn_horse' | 'avatar/icn_hyena' | 'avatar/icn_kangaroo' | 'avatar/icn_lemur' | 'avatar/icn_mammel' | 'avatar/icn_monkey' | 'avatar/icn_moose' | 'avatar/icn_panda' | 'avatar/icn_penguin' | 'avatar/icn_porcupine' | 'avatar/icn_quail' | 'avatar/icn_rabbit' | 'avatar/icn_rhino' | 'avatar/icn_sea_horse' | 'avatar/icn_sheep' | 'avatar/icn_snake' | 'avatar/icn_squirrel' | 'avatar/icn_tapir' | 'avatar/icn_turtle' | 'avatar/icn_vulture' | 'avatar/icn_wild1' | 'avatar/icn_wild_bore' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'bell-fill' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'bullhorn' | 'business-time' | 'calendar-alt' | 'calendar-check' | 'calendar-day' | 'calendar' | 'call' | 'camera-alt' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-checklist' | 'card-text' | 'caret-down-fill' | 'caret-left-fill' | 'caret-right-fill' | 'caret-up-fill' | 'chat-dots' | 'chat-right-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-double-left' | 'chevron-double-right' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'clipboard-list-check' | 'clock' | 'close' | 'cloud-fog2-fill' | 'code' | 'cog' | 'cogs' | 'collection' | 'columns-gap-filled' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-front' | 'cross' | 'cubes' | 'dash' | 'dashboard-icn' | 'desktop' | 'device' | 'diagram-3' | 'dizzy' | 'door-closed' | 'doublecheck' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'enter' | 'envelope' | 'errors-icon' | 'event/click' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/link' | 'event/location' | 'event/resize' | 'event/view' | 'exclamation-circle' | 'expand-wide' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/state-action' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel-new' | 'funnel' | 'gear-fill' | 'gear' | 'geo-alt-fill-custom' | 'github' | 'graph-up-arrow' | 'graph-up' | 'grid-1x2' | 'grid-3x3' | 'grid-check' | 'grid-horizontal' | 'grid' | 'grip-horizontal' | 'hash' | 'hdd-stack' | 'headset' | 'heart-rate' | 'high-engagement' | 'history' | 'hourglass-start' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'journal-code' | 'layer-group' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-arrow' | 'list-ul' | 'list' | 'lock-alt' | 'magic' | 'map-marker-alt' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'percent' | 'performance-icon' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plus-circle' | 'plus-lg' | 'plus' | 'pointer-sessions-search' | 'prev1' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quote-left' | 'quote-right' | 'quotes' | 'record-circle' | 'redo-back' | 'redo' | 'remote-control' | 'replay-10' | 'resources-icon' | 'safe-fill' | 'safe' | 'sandglass' | 'search' | 'search_notification' | 'server' | 'share-alt' | 'shield-lock' | 'signpost-split' | 'signup' | 'skip-forward-fill' | 'skip-forward' | 'slack' | 'slash-circle' | 'sliders' | 'social/slack' | 'social/trello' | 'speedometer2' | 'spinner' | 'star-solid' | 'star' | 'step-forward' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table-new' | 'table' | 'tablet-android' | 'tachometer-slow' | 'tachometer-slowest' | 'tags' | 'team-funnel' | 'telephone-fill' | 'telephone' | 'text-paragraph' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'users' | 'vendors/graphql' | 'vendors/mobx' | 'vendors/ngrx' | 'vendors/redux' | 'vendors/vuex' | 'web-vitals' | 'wifi' | 'window-alt' | 'window-restore' | 'window-x' | 'window' | 'zoom-in'; +export type IconNames = 'activity' | 'alarm-clock' | 'alarm-plus' | 'all-sessions' | 'analytics' | 'anchor' | 'arrow-alt-square-right' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-square-left' | 'arrow-square-right' | 'arrow-up-short' | 'arrow-up' | 'arrows-angle-extend' | 'avatar/icn_bear' | 'avatar/icn_beaver' | 'avatar/icn_bird' | 'avatar/icn_bison' | 'avatar/icn_camel' | 'avatar/icn_chameleon' | 'avatar/icn_deer' | 'avatar/icn_dog' | 'avatar/icn_dolphin' | 'avatar/icn_elephant' | 'avatar/icn_fish' | 'avatar/icn_fox' | 'avatar/icn_gorilla' | 'avatar/icn_hippo' | 'avatar/icn_horse' | 'avatar/icn_hyena' | 'avatar/icn_kangaroo' | 'avatar/icn_lemur' | 'avatar/icn_mammel' | 'avatar/icn_monkey' | 'avatar/icn_moose' | 'avatar/icn_panda' | 'avatar/icn_penguin' | 'avatar/icn_porcupine' | 'avatar/icn_quail' | 'avatar/icn_rabbit' | 'avatar/icn_rhino' | 'avatar/icn_sea_horse' | 'avatar/icn_sheep' | 'avatar/icn_snake' | 'avatar/icn_squirrel' | 'avatar/icn_tapir' | 'avatar/icn_turtle' | 'avatar/icn_vulture' | 'avatar/icn_wild1' | 'avatar/icn_wild_bore' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'bell-fill' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'bullhorn' | 'business-time' | 'calendar-alt' | 'calendar-check' | 'calendar-day' | 'calendar' | 'call' | 'camera-alt' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-checklist' | 'card-text' | 'caret-down-fill' | 'caret-left-fill' | 'caret-right-fill' | 'caret-up-fill' | 'chat-dots' | 'chat-right-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-double-left' | 'chevron-double-right' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'click-hesitation' | 'click-rage' | 'clipboard-list-check' | 'clock' | 'close' | 'cloud-fog2-fill' | 'code' | 'cog' | 'cogs' | 'collection' | 'columns-gap-filled' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-front' | 'cross' | 'cubes' | 'cursor-trash' | 'dash' | 'dashboard-icn' | 'desktop' | 'device' | 'diagram-3' | 'dizzy' | 'door-closed' | 'doublecheck' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'enter' | 'envelope' | 'errors-icon' | 'event/click' | 'event/click_hesitation' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/input_hesitation' | 'event/link' | 'event/location' | 'event/mouse_thrashing' | 'event/resize' | 'event/view' | 'exclamation-circle' | 'expand-wide' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/state-action' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel-new' | 'funnel' | 'gear-fill' | 'gear' | 'geo-alt-fill-custom' | 'github' | 'graph-up-arrow' | 'graph-up' | 'grid-1x2' | 'grid-3x3' | 'grid-check' | 'grid-horizontal' | 'grid' | 'grip-horizontal' | 'hash' | 'hdd-stack' | 'headset' | 'heart-rate' | 'high-engagement' | 'history' | 'hourglass-start' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'input-hesitation' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'journal-code' | 'layer-group' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-arrow' | 'list-ul' | 'list' | 'lock-alt' | 'magic' | 'map-marker-alt' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'percent' | 'performance-icon' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plus-circle' | 'plus-lg' | 'plus' | 'pointer-sessions-search' | 'prev1' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quote-left' | 'quote-right' | 'quotes' | 'record-circle' | 'redo-back' | 'redo' | 'remote-control' | 'replay-10' | 'resources-icon' | 'safe-fill' | 'safe' | 'sandglass' | 'search' | 'search_notification' | 'server' | 'share-alt' | 'shield-lock' | 'signpost-split' | 'signup' | 'skip-forward-fill' | 'skip-forward' | 'slack' | 'slash-circle' | 'sliders' | 'social/slack' | 'social/trello' | 'speedometer2' | 'spinner' | 'star-solid' | 'star' | 'step-forward' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table-new' | 'table' | 'tablet-android' | 'tachometer-slow' | 'tachometer-slowest' | 'tags' | 'team-funnel' | 'telephone-fill' | 'telephone' | 'text-paragraph' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'users' | 'vendors/graphql' | 'vendors/mobx' | 'vendors/ngrx' | 'vendors/redux' | 'vendors/vuex' | 'web-vitals' | 'wifi' | 'window-alt' | 'window-restore' | 'window-x' | 'window' | 'zoom-in'; interface Props { name: IconNames; @@ -119,6 +119,8 @@ const SVG = (props: Props) => { case 'chevron-up': return ; case 'circle-fill': return ; case 'circle': return ; + case 'click-hesitation': return ; + case 'click-rage': return ; case 'clipboard-list-check': return ; case 'clock': return ; case 'close': return ; @@ -140,6 +142,7 @@ const SVG = (props: Props) => { case 'credit-card-front': return ; case 'cross': return ; case 'cubes': return ; + case 'cursor-trash': return ; case 'dash': return ; case 'dashboard-icn': return ; case 'desktop': return ; @@ -156,12 +159,15 @@ const SVG = (props: Props) => { case 'envelope': return ; case 'errors-icon': return ; case 'event/click': return ; + case 'event/click_hesitation': return ; case 'event/clickrage': return ; case 'event/code': return ; case 'event/i-cursor': return ; case 'event/input': return ; + case 'event/input_hesitation': return ; case 'event/link': return ; case 'event/location': return ; + case 'event/mouse_thrashing': return ; case 'event/resize': return ; case 'event/view': return ; case 'exclamation-circle': return ; @@ -271,6 +277,7 @@ const SVG = (props: Props) => { case 'info-circle': return ; case 'info-square': return ; case 'info': return ; + case 'input-hesitation': return ; case 'inspect': return ; case 'integrations/assist': return ; case 'integrations/bugsnag-text': return ; diff --git a/frontend/app/components/ui/Tooltip/Tooltip.tsx b/frontend/app/components/ui/Tooltip/Tooltip.tsx index fcb5e1687..ee68b71e7 100644 --- a/frontend/app/components/ui/Tooltip/Tooltip.tsx +++ b/frontend/app/components/ui/Tooltip/Tooltip.tsx @@ -23,6 +23,7 @@ function Tooltip(props: Props) { placement, className = '', anchorClassName = '', + containerClassName = '', delay = 500, style = {}, offset = 5, @@ -39,7 +40,7 @@ function Tooltip(props: Props) { }); return ( -
+
{props.children} = new ListWalker(); + private mouseThrashingManager: ListWalker = new ListWalker(); private resizeManager: ListWalker = new ListWalker([]); private pagesManager: PagesManager; @@ -345,9 +346,13 @@ export default class MessageManager { // Moving mouse and setting :hover classes on ready view this.mouseMoveManager.move(t); const lastClick = this.clickManager.moveGetLast(t); - if (!!lastClick && t - lastClick.time < 600) { // happend during last 600ms + if (!!lastClick && t - lastClick.time < 600) { // happened during last 600ms this.screen.cursor.click(); } + const lastThrashing = this.mouseThrashingManager.moveGetLast(t) + if (!!lastThrashing && t - lastThrashing.time < 300) { + this.screen.cursor.shake(); + } }) if (this.waitingForFiles && this.lastMessageTime <= t && t !== this.session.duration.milliseconds) { @@ -388,6 +393,9 @@ export default class MessageManager { case MType.SetViewportSize: this.resizeManager.append(msg); break; + case MType.MouseThrashing: + this.mouseThrashingManager.append(msg); + break; case MType.MouseMove: this.mouseMoveManager.append(msg); break; diff --git a/frontend/app/player/web/Screen/Cursor.ts b/frontend/app/player/web/Screen/Cursor.ts index f2d371062..dc3637c1b 100644 --- a/frontend/app/player/web/Screen/Cursor.ts +++ b/frontend/app/player/web/Screen/Cursor.ts @@ -3,9 +3,11 @@ import styles from './cursor.module.css'; export default class Cursor { + private readonly isMobile: boolean; private readonly cursor: HTMLDivElement; private tagElement: HTMLDivElement; - private isMobile: boolean; + private coords = { x: 0, y: 0 }; + private isMoving = false; constructor(overlay: HTMLDivElement, isMobile: boolean) { this.cursor = document.createElement('div'); @@ -50,8 +52,24 @@ export default class Cursor { } move({ x, y }: Point) { + this.isMoving = true; this.cursor.style.left = x + 'px'; this.cursor.style.top = y + 'px'; + this.coords = { x, y }; + setTimeout(() => this.isMoving = false, 60) + } + + setDefaultStyle() { + this.cursor.style.width = 18 + 'px' + this.cursor.style.height = 30 + 'px' + this.cursor.style.transition = 'top .125s linear, left .125s linear' + } + + shake() { + this.cursor.classList.add(styles.shaking) + setTimeout(() => { + this.cursor.classList.remove(styles.shaking) + }, 500) } click() { @@ -62,7 +80,7 @@ export default class Cursor { }, 600) } - // TODO (to keep on a different playig speed): + // TODO (to keep on a different playing speed): // transition // setTransitionSpeed() diff --git a/frontend/app/player/web/Screen/cursor.module.css b/frontend/app/player/web/Screen/cursor.module.css index 93f3d05ff..238a53ca1 100644 --- a/frontend/app/player/web/Screen/cursor.module.css +++ b/frontend/app/player/web/Screen/cursor.module.css @@ -1,9 +1,9 @@ .cursor { display: block; position: absolute; - width: 13px; - height: 20px; - background-image: url('data:image/svg+xml;utf8,'); + width: 18px; + height: 30px; + background-image: url('data:image/svg+xml;utf8, '); background-repeat: no-repeat; transition: top .125s linear, left .125s linear; @@ -108,3 +108,29 @@ transform: scale3d(1, 1, 1); } } + +.cursor.shaking { + width: 45px; + height: 75px; + -webkit-animation: shaking 0.3s linear; + animation: shaking 0.3s linear; + animation-iteration-count: 2; +} + +@keyframes shaking { + 0% { + transform: translate(60px, -60px); + } + 25% { + transform: translate(-60px, 60px); + } + 50% { + transform: translate(60px, -60px); + } + 75% { + transform: translate(-60px, 60px); + } + 100% { + transform: translate(60px, -60px); + } +} \ No newline at end of file diff --git a/frontend/app/player/web/WebPlayer.ts b/frontend/app/player/web/WebPlayer.ts index 5cd255e10..d307b4308 100644 --- a/frontend/app/player/web/WebPlayer.ts +++ b/frontend/app/player/web/WebPlayer.ts @@ -31,6 +31,7 @@ export default class WebPlayer extends Player { let initialLists = live ? {} : { event: session.events || [], stack: session.stackEvents || [], + frustrations: session.frustrations || [], exceptions: session.errors?.map(({ name, ...rest }: any) => Log({ level: LogLevel.ERROR, diff --git a/frontend/app/player/web/managers/DOM/DOMManager.ts b/frontend/app/player/web/managers/DOM/DOMManager.ts index bb4c999ae..7b773860c 100644 --- a/frontend/app/player/web/managers/DOM/DOMManager.ts +++ b/frontend/app/player/web/managers/DOM/DOMManager.ts @@ -2,25 +2,24 @@ import logger from 'App/logger'; import type Screen from '../../Screen/Screen'; import type { Message, SetNodeScroll } from '../../messages'; - import { MType } from '../../messages'; import ListWalker from '../../../common/ListWalker'; import StylesManager, { rewriteNodeStyleSheet } from './StylesManager'; import FocusManager from './FocusManager'; -import { - VElement, - VText, - VShadowRoot, - VDocument, - VNode, - VStyleElement, - PostponedStyleSheet, -} from './VirtualDOM'; +import SelectionManager from './SelectionManager'; import type { StyleElement } from './VirtualDOM'; -import { insertRule, deleteRule } from './safeCSSRules'; +import { + PostponedStyleSheet, + VDocument, + VElement, + VNode, + VShadowRoot, + VStyleElement, + VText, +} from './VirtualDOM'; +import { deleteRule, insertRule } from './safeCSSRules'; - -type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement +type HTMLElementWithValue = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; const IGNORED_ATTRS = [ "autocomplete" ]; const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/; // regexp costs ~ @@ -50,7 +49,7 @@ export default class DOMManager extends ListWalker { private nodeScrollManagers: Map> = new Map() private stylesManager: StylesManager private focusManager: FocusManager = new FocusManager(this.vElements) - + private selectionManager: SelectionManager constructor( private readonly screen: Screen, @@ -59,6 +58,7 @@ export default class DOMManager extends ListWalker { setCssLoading: ConstructorParameters[1], ) { super() + this.selectionManager = new SelectionManager(this.vElements, screen) this.stylesManager = new StylesManager(screen, setCssLoading) } @@ -76,6 +76,10 @@ export default class DOMManager extends ListWalker { this.focusManager.append(m) return } + if (m.tp === MType.SelectionChange) { + this.selectionManager.append(m) + return + } if (m.tp === MType.CreateElementNode) { if(m.tag === "BODY" && this.upperBodyId === -1) { this.upperBodyId = m.id @@ -287,7 +291,7 @@ export default class DOMManager extends ListWalker { } return - // @depricated since 4.0.2 in favor of adopted_ss_insert/delete_rule + add_owner as being common case for StyleSheets + // @deprecated since 4.0.2 in favor of adopted_ss_insert/delete_rule + add_owner as being common case for StyleSheets case MType.CssInsertRule: vn = this.vElements.get(msg.id) if (!vn) { logger.error("Node not found", msg); return } @@ -306,7 +310,7 @@ export default class DOMManager extends ListWalker { } vn.onStyleSheet(sheet => deleteRule(sheet, msg)) return - // end @depricated + // end @deprecated case MType.CreateIFrameDocument: vn = this.vElements.get(msg.frameID) @@ -432,6 +436,7 @@ export default class DOMManager extends ListWalker { return this.stylesManager.moveReady(t).then(() => { // Apply focus this.focusManager.move(t) + this.selectionManager.move(t) // Apply all scrolls after the styles got applied this.nodeScrollManagers.forEach(manager => { const msg = manager.moveGetLast(t) diff --git a/frontend/app/player/web/managers/DOM/FocusManager.ts b/frontend/app/player/web/managers/DOM/FocusManager.ts index 174335473..c75f3ddc3 100644 --- a/frontend/app/player/web/managers/DOM/FocusManager.ts +++ b/frontend/app/player/web/managers/DOM/FocusManager.ts @@ -3,7 +3,6 @@ import type { SetNodeFocus } from '../../messages'; import type { VElement } from './VirtualDOM'; import ListWalker from '../../../common/ListWalker'; - const FOCUS_CLASS = "-openreplay-focus" export default class FocusManager extends ListWalker { diff --git a/frontend/app/player/web/managers/DOM/SelectionManager.ts b/frontend/app/player/web/managers/DOM/SelectionManager.ts new file mode 100644 index 000000000..c457ec3b8 --- /dev/null +++ b/frontend/app/player/web/managers/DOM/SelectionManager.ts @@ -0,0 +1,75 @@ +import type { SelectionChange } from '../../messages'; +import type { VElement } from './VirtualDOM'; +import ListWalker from '../../../common/ListWalker'; +import Screen from 'Player/web/Screen/Screen'; + +export default class SelectionManager extends ListWalker { + constructor(private readonly vElements: Map, private readonly screen: Screen) { + super(); + } + + private selected: [{ id: number, node: Element } | null, { id: number, node: Element } | null] = [null, null]; + private markers: Element[] = [] + + clearSelection() { + this.selected[0] && this.screen.overlay.removeChild(this.selected[0].node) && this.selected[0].node.remove(); + this.selected[1] && this.screen.overlay.removeChild(this.selected[1].node) && this.selected[1].node.remove(); + this.markers.forEach(marker => marker.remove()) + + this.selected = [null, null]; + this.markers = []; + } + + move(t: number) { + const msg = this.moveGetLast(t); + if (!msg) { + return; + } + // in theory: empty selection or selection removed + if (msg.selectionStart <= 0) { + this.clearSelection() + return; + } + // preventing clones + if ((this.selected[0] && this.selected[0].id === msg.selectionStart) && (this.selected[1] && this.selected[1].id === msg.selectionEnd)) return; + + const startVNode = this.vElements.get(msg.selectionStart - 1); + const endVNode = this.vElements.get(msg.selectionEnd - 1); + + // only one selection present on page at the same time + if (this.selected[0] && this.selected[0]?.id !== msg.selectionStart) this.clearSelection() + + if (startVNode && endVNode) { + const startCoords = startVNode.node.getBoundingClientRect(); + const endCoords = endVNode.node.getBoundingClientRect(); + + const startPointer = document.createElement('div'); + const endPointer = document.createElement('div'); + + Object.assign(endPointer.style, { + top: endCoords.top + 'px', + left: (endCoords.left + (endCoords.width / 2) + 3) + 'px', + width: (endCoords.width / 2) + 'px', + height: endCoords.height + 'px', + borderRight: '2px solid blue', + position: 'absolute', + boxShadow: '1px 4px 1px -2px blue', + }); + Object.assign(startPointer.style, { + top: startCoords.top + 'px', + left: (startCoords.left - 3) + 'px', + width: (startCoords.width / 2 ) + 'px', + height: startCoords.height + 'px', + borderLeft: '2px solid blue', + position: 'absolute', + boxShadow: '1px 4px 1px -2px blue', + }); + + this.markers.push(startPointer, endPointer); + this.screen.overlay.appendChild(startPointer); + this.screen.overlay.appendChild(endPointer); + + this.selected = [{ id: msg.selectionStart, node: startPointer }, { id: msg.selectionEnd, node: endPointer }]; + } + } +} diff --git a/frontend/app/player/web/managers/DOM/VirtualDOM.ts b/frontend/app/player/web/managers/DOM/VirtualDOM.ts index 1be12d68c..91b75eb24 100644 --- a/frontend/app/player/web/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/web/managers/DOM/VirtualDOM.ts @@ -122,7 +122,7 @@ export class VElement extends VParent { type StyleSheetCallback = (s: CSSStyleSheet) => void export type StyleElement = HTMLStyleElement | SVGStyleElement -// @Depricated TODO: remove in favor of PostponedStyleSheet +// @deprecated TODO: remove in favor of PostponedStyleSheet export class VStyleElement extends VElement { private loaded = false private stylesheetCallbacks: StyleSheetCallback[] = [] diff --git a/frontend/app/player/web/managers/DOM/selection.module.css b/frontend/app/player/web/managers/DOM/selection.module.css new file mode 100644 index 000000000..c677827bf --- /dev/null +++ b/frontend/app/player/web/managers/DOM/selection.module.css @@ -0,0 +1,7 @@ +.openreplay-selection-start { + border: 2px solid red; +} + +.openreplay-selection-end { + border: 2px solid red; +} \ No newline at end of file diff --git a/frontend/app/player/web/managers/MouseMoveManager.ts b/frontend/app/player/web/managers/MouseMoveManager.ts index 1b19d7e5b..fa320ab13 100644 --- a/frontend/app/player/web/managers/MouseMoveManager.ts +++ b/frontend/app/player/web/managers/MouseMoveManager.ts @@ -1,5 +1,5 @@ import type Screen from '../Screen/Screen' -import type { MouseMove } from '../messages' +import type { MouseMove } from "../messages"; import ListWalker from '../../common/ListWalker' diff --git a/frontend/app/player/web/messages/RawMessageReader.gen.ts b/frontend/app/player/web/messages/RawMessageReader.gen.ts index 793f609f5..1d0e0e7cd 100644 --- a/frontend/app/player/web/messages/RawMessageReader.gen.ts +++ b/frontend/app/player/web/messages/RawMessageReader.gen.ts @@ -627,6 +627,26 @@ export default class RawMessageReader extends PrimitiveReader { }; } + case 113: { + const selectionStart = this.readUint(); if (selectionStart === null) { return resetPointer() } + const selectionEnd = this.readUint(); if (selectionEnd === null) { return resetPointer() } + const selection = this.readString(); if (selection === null) { return resetPointer() } + return { + tp: MType.SelectionChange, + selectionStart, + selectionEnd, + selection, + }; + } + + case 114: { + const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } + return { + tp: MType.MouseThrashing, + timestamp, + }; + } + case 90: { const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() } const projectID = this.readUint(); if (projectID === null) { return resetPointer() } diff --git a/frontend/app/player/web/messages/filters.gen.ts b/frontend/app/player/web/messages/filters.gen.ts index cd664201e..2cd1b6c25 100644 --- a/frontend/app/player/web/messages/filters.gen.ts +++ b/frontend/app/player/web/messages/filters.gen.ts @@ -3,7 +3,7 @@ import { MType } from './raw.gen' -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,90,93,96,100,102,103,105] +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,90,93,96,100,102,103,105] 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 b39d9ce46..e39d02584 100644 --- a/frontend/app/player/web/messages/message.gen.ts +++ b/frontend/app/player/web/messages/message.gen.ts @@ -55,6 +55,8 @@ import type { RawAdoptedSsAddOwner, RawAdoptedSsRemoveOwner, RawZustand, + RawSelectionChange, + RawMouseThrashing, RawIosSessionStart, RawIosCustomEvent, RawIosScreenChanges, @@ -169,6 +171,10 @@ export type AdoptedSsRemoveOwner = RawAdoptedSsRemoveOwner & Timed export type Zustand = RawZustand & Timed +export type SelectionChange = RawSelectionChange & Timed + +export type MouseThrashing = RawMouseThrashing & Timed + export type IosSessionStart = RawIosSessionStart & Timed export type IosCustomEvent = RawIosCustomEvent & Timed diff --git a/frontend/app/player/web/messages/raw.gen.ts b/frontend/app/player/web/messages/raw.gen.ts index b51edb40e..67f65ab35 100644 --- a/frontend/app/player/web/messages/raw.gen.ts +++ b/frontend/app/player/web/messages/raw.gen.ts @@ -53,6 +53,8 @@ export const enum MType { AdoptedSsAddOwner = 76, AdoptedSsRemoveOwner = 77, Zustand = 79, + SelectionChange = 113, + MouseThrashing = 114, IosSessionStart = 90, IosCustomEvent = 93, IosScreenChanges = 96, @@ -418,6 +420,18 @@ export interface RawZustand { state: string, } +export interface RawSelectionChange { + tp: MType.SelectionChange, + selectionStart: number, + selectionEnd: number, + selection: string, +} + +export interface RawMouseThrashing { + tp: MType.MouseThrashing, + timestamp: number, +} + export interface RawIosSessionStart { tp: MType.IosSessionStart, timestamp: number, @@ -489,4 +503,4 @@ export interface RawIosNetworkCall { } -export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTiming | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; +export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTiming | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawIosSessionStart | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall; diff --git a/frontend/app/player/web/messages/tracker-legacy.gen.ts b/frontend/app/player/web/messages/tracker-legacy.gen.ts index bd8ba9c85..ce69f34b3 100644 --- a/frontend/app/player/web/messages/tracker-legacy.gen.ts +++ b/frontend/app/player/web/messages/tracker-legacy.gen.ts @@ -54,6 +54,8 @@ export const TP_MAP = { 76: MType.AdoptedSsAddOwner, 77: MType.AdoptedSsRemoveOwner, 79: MType.Zustand, + 113: MType.SelectionChange, + 114: MType.MouseThrashing, 90: MType.IosSessionStart, 93: MType.IosCustomEvent, 96: MType.IosScreenChanges, diff --git a/frontend/app/player/web/messages/tracker.gen.ts b/frontend/app/player/web/messages/tracker.gen.ts index 2c171011c..fdd9c6f42 100644 --- a/frontend/app/player/web/messages/tracker.gen.ts +++ b/frontend/app/player/web/messages/tracker.gen.ts @@ -429,8 +429,30 @@ type TrPartitionedMessage = [ partTotal: number, ] +type TrInputChange = [ + type: 112, + id: number, + value: string, + valueMasked: boolean, + label: string, + hesitationTime: number, + inputDuration: number, +] -export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTiming | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage +type TrSelectionChange = [ + type: 113, + selectionStart: number, + selectionEnd: number, + selection: string, +] + +type TrMouseThrashing = [ + type: 114, + timestamp: number, +] + + +export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTiming | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing export default function translate(tMsg: TrackerMessage): RawMessage | null { switch(tMsg[0]) { @@ -867,6 +889,22 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null { } } + case 113: { + return { + tp: MType.SelectionChange, + selectionStart: tMsg[1], + selectionEnd: tMsg[2], + selection: tMsg[3], + } + } + + case 114: { + return { + tp: MType.MouseThrashing, + timestamp: tMsg[1], + } + } + default: return null } diff --git a/frontend/app/svg/icons/click-hesitation.svg b/frontend/app/svg/icons/click-hesitation.svg new file mode 100644 index 000000000..144b82cd5 --- /dev/null +++ b/frontend/app/svg/icons/click-hesitation.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/click-rage.svg b/frontend/app/svg/icons/click-rage.svg new file mode 100644 index 000000000..54ccb06a6 --- /dev/null +++ b/frontend/app/svg/icons/click-rage.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/app/svg/icons/cursor-trash.svg b/frontend/app/svg/icons/cursor-trash.svg new file mode 100644 index 000000000..bdf687c91 --- /dev/null +++ b/frontend/app/svg/icons/cursor-trash.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/event/click_hesitation.svg b/frontend/app/svg/icons/event/click_hesitation.svg new file mode 100644 index 000000000..144b82cd5 --- /dev/null +++ b/frontend/app/svg/icons/event/click_hesitation.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/event/input_hesitation.svg b/frontend/app/svg/icons/event/input_hesitation.svg new file mode 100644 index 000000000..a2f79bfb6 --- /dev/null +++ b/frontend/app/svg/icons/event/input_hesitation.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/event/mouse_thrashing.svg b/frontend/app/svg/icons/event/mouse_thrashing.svg new file mode 100644 index 000000000..af00c02cf --- /dev/null +++ b/frontend/app/svg/icons/event/mouse_thrashing.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/input-hesitation.svg b/frontend/app/svg/icons/input-hesitation.svg new file mode 100644 index 000000000..439606a52 --- /dev/null +++ b/frontend/app/svg/icons/input-hesitation.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/app/types/session/event.ts b/frontend/app/types/session/event.ts index b08f07140..edb63cda7 100644 --- a/frontend/app/types/session/event.ts +++ b/frontend/app/types/session/event.ts @@ -5,12 +5,20 @@ const LOCATION = 'LOCATION'; const CUSTOM = 'CUSTOM'; const CLICKRAGE = 'CLICKRAGE'; const IOS_VIEW = 'VIEW'; -export const TYPES = { CONSOLE, CLICK, INPUT, LOCATION, CUSTOM, CLICKRAGE, IOS_VIEW}; +export const TYPES = { CONSOLE, CLICK, INPUT, LOCATION, CUSTOM, CLICKRAGE, IOS_VIEW }; + +export type EventType = + | typeof CONSOLE + | typeof CLICK + | typeof INPUT + | typeof LOCATION + | typeof CUSTOM + | typeof CLICKRAGE; interface IEvent { time: number; timestamp: number; - type: typeof CONSOLE | typeof CLICK | typeof INPUT | typeof LOCATION | typeof CUSTOM | typeof CLICKRAGE; + type: EventType name: string; key: number; label: string; @@ -18,18 +26,24 @@ interface IEvent { target: { path: string; label: string; - } + }; } + interface ConsoleEvent extends IEvent { - subtype: string - value: string + subtype: string; + value: string; } + interface ClickEvent extends IEvent { targetContent: string; count: number; + hesitation: number; } + interface InputEvent extends IEvent { value: string; + hesitation: number; + duration: number; } export interface LocationEvent extends IEvent { @@ -51,11 +65,10 @@ export interface LocationEvent extends IEvent { export type EventData = ConsoleEvent | ClickEvent | InputEvent | LocationEvent | IEvent; class Event { - key: IEvent["key"] - time: IEvent["time"]; - label: IEvent["label"]; - target: IEvent["target"]; - + key: IEvent['key']; + time: IEvent['time']; + label: IEvent['label']; + target: IEvent['target']; constructor(event: IEvent) { Object.assign(this, { @@ -64,98 +77,103 @@ class Event { key: event.key, target: { path: event.target?.path || event.targetPath, - label: event.target?.label - } - }) + label: event.target?.label, + }, + }); } } class Console extends Event { readonly type = CONSOLE; - readonly name = 'Console' + readonly name = 'Console'; subtype: string; value: string; constructor(evt: ConsoleEvent) { super(evt); - this.subtype = evt.subtype - this.value = evt.value + this.subtype = evt.subtype; + this.value = evt.value; } } export class Click extends Event { readonly type: typeof CLICKRAGE | typeof CLICK = CLICK; - readonly name = 'Click' + readonly name = 'Click'; targetContent = ''; - count: number + count: number; + hesitation: number = 0; constructor(evt: ClickEvent, isClickRage?: boolean) { super(evt); - this.targetContent = evt.targetContent - this.count = evt.count + this.targetContent = evt.targetContent; + this.count = evt.count; + this.hesitation = evt.hesitation; if (isClickRage) { - this.type = CLICKRAGE + this.type = CLICKRAGE; } } } class Input extends Event { readonly type = INPUT; - readonly name = 'Input' - value = '' + readonly name = 'Input'; + readonly hesitation: number = 0; + readonly duration: number = 0; + + value = ''; constructor(evt: InputEvent) { super(evt); - this.value = evt.value + this.value = evt.value; + this.hesitation = evt.hesitation; + this.duration = evt.duration; } } - export class Location extends Event { readonly name = 'Location'; readonly type = LOCATION; - url: LocationEvent["url"] - host: LocationEvent["host"]; - fcpTime: LocationEvent["fcpTime"]; - loadTime: LocationEvent["loadTime"]; - domContentLoadedTime: LocationEvent["domContentLoadedTime"]; - domBuildingTime: LocationEvent["domBuildingTime"]; - speedIndex: LocationEvent["speedIndex"]; - visuallyComplete: LocationEvent["visuallyComplete"]; - timeToInteractive: LocationEvent["timeToInteractive"]; - referrer: LocationEvent["referrer"]; + url: LocationEvent['url']; + host: LocationEvent['host']; + fcpTime: LocationEvent['fcpTime']; + loadTime: LocationEvent['loadTime']; + domContentLoadedTime: LocationEvent['domContentLoadedTime']; + domBuildingTime: LocationEvent['domBuildingTime']; + speedIndex: LocationEvent['speedIndex']; + visuallyComplete: LocationEvent['visuallyComplete']; + timeToInteractive: LocationEvent['timeToInteractive']; + referrer: LocationEvent['referrer']; constructor(evt: LocationEvent) { super(evt); Object.assign(this, { ...evt, - fcpTime: evt.firstContentfulPaintTime || evt.firstPaintTime + fcpTime: evt.firstContentfulPaintTime || evt.firstPaintTime, }); } } export type InjectedEvent = Console | Click | Input | Location; -export default function(event: EventData) { +export default function (event: EventData) { if (event.type && event.type === CONSOLE) { - return new Console(event as ConsoleEvent) + return new Console(event as ConsoleEvent); } if (event.type && event.type === CLICK) { - return new Click(event as ClickEvent) + return new Click(event as ClickEvent); } if (event.type && event.type === INPUT) { - return new Input(event as InputEvent) + return new Input(event as InputEvent); } if (event.type && event.type === LOCATION) { - return new Location(event as LocationEvent) + return new Location(event as LocationEvent); } if (event.type && event.type === CLICKRAGE) { - return new Click(event as ClickEvent, true) + return new Click(event as ClickEvent, true); } // not used right now? // if (event.type === CUSTOM || !event.type) { // return new Event(event) // } - console.error(`Unknown event type: ${event.type}`) + console.error(`Unknown event type: ${event.type}`); } - diff --git a/frontend/app/types/session/issue.ts b/frontend/app/types/session/issue.ts index 68ab64001..8f5f415e3 100644 --- a/frontend/app/types/session/issue.ts +++ b/frontend/app/types/session/issue.ts @@ -1,11 +1,12 @@ import Record from 'Types/Record'; -const types = { +export const types = { ALL: 'all', JS_EXCEPTION: 'js_exception', BAD_REQUEST: 'bad_request', CRASH: 'crash', - CLICK_RAGE: 'click_rage' + CLICK_RAGE: 'click_rage', + MOUSE_THRASHING: 'mouse_thrashing', } as const type TypeKeys = keyof typeof types @@ -21,6 +22,7 @@ export const issues_types = [ { 'type': types.BAD_REQUEST, 'visible': true, 'order': 2, 'name': 'Bad Requests', 'icon': 'funnel/file-medical-alt' }, { 'type': types.CLICK_RAGE, 'visible': true, 'order': 3, 'name': 'Click Rage', 'icon': 'funnel/emoji-angry' }, { 'type': types.CRASH, 'visible': true, 'order': 4, 'name': 'Crashes', 'icon': 'funnel/file-earmark-break' }, + { 'type': types.MOUSE_THRASHING, 'visible': true, 'order': 5, 'name': 'Mouse Thrashing', 'icon': 'close' }, // { 'type': 'memory', 'visible': true, 'order': 4, 'name': 'High Memory', 'icon': 'funnel/sd-card' }, // { 'type': 'vault', 'visible': true, 'order': 5, 'name': 'Vault', 'icon': 'safe' }, // { 'type': 'bookmark', 'visible': true, 'order': 5, 'name': 'Bookmarks', 'icon': 'safe' }, diff --git a/frontend/app/types/session/session.ts b/frontend/app/types/session/session.ts index 7b1f95061..239871600 100644 --- a/frontend/app/types/session/session.ts +++ b/frontend/app/types/session/session.ts @@ -2,13 +2,44 @@ import { Duration } from 'luxon'; import SessionEvent, { TYPES, EventData, InjectedEvent } from './event'; import StackEvent from './stackEvent'; import SessionError, { IError } from './error'; -import Issue, { IIssue } from './issue'; +import Issue, { IIssue, types as issueTypes } from './issue'; import { Note } from 'App/services/NotesService'; import { toJS } from 'mobx'; const HASH_MOD = 1610612741; const HASH_P = 53; +function mergeEventLists, Y extends Record>(arr1: T[], arr2: Y[]): Array { + let merged = []; + let index1 = 0; + let index2 = 0; + let current = 0; + + while (current < (arr1.length + arr2.length)) { + + let isArr1Depleted = index1 >= arr1.length; + let isArr2Depleted = index2 >= arr2.length; + + if (!isArr1Depleted && (isArr2Depleted || (arr1[index1].timestamp < arr2[index2].timestamp))) { + merged[current] = arr1[index1]; + index1++; + } else { + merged[current] = arr2[index2]; + index2++; + } + + current++; + } + + return merged; +} +function sortEvents(a: Record, b: Record) { + const aTs = a.timestamp || a.time; + const bTs = b.timestamp || b.time; + + return aTs - bTs; +} + function hashString(s: string): number { let mul = 1; let hash = 0; @@ -159,6 +190,8 @@ export default class Session { agentToken: ISession['agentToken']; notes: ISession['notes']; notesWithEvents: ISession['notesWithEvents']; + frustrations: Array + fileKey: ISession['fileKey']; durationSeconds: number; @@ -220,15 +253,24 @@ export default class Session { (i, k) => new Issue({ ...i, time: i.timestamp - startedAt, key: k }) ) || []; - const notesWithEvents = - [...rawEvents, ...notes].sort((a, b) => { - // @ts-ignore just in case - const aTs = a.timestamp || a.time; - // @ts-ignore - const bTs = b.timestamp || b.time; + const rawNotes = notes; - return aTs - bTs; - }) || []; + const frustrationEvents = events.filter(ev => { + if (ev.type === TYPES.CLICK || ev.type === TYPES.INPUT) { + // @ts-ignore + return ev.hesitation > 1000 + } + return ev.type === TYPES.CLICKRAGE + } + ) + const frustrationIssues = issuesList.filter(i => i.type === issueTypes.MOUSE_THRASHING) + + const frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || []; + + const mixedEventsWithIssues = mergeEventLists( + mergeEventLists(rawEvents, rawNotes), + frustrationIssues + ).sort(sortEvents) Object.assign(this, { ...session, @@ -260,7 +302,8 @@ export default class Session { domURL, devtoolsURL, notes, - notesWithEvents: notesWithEvents, + notesWithEvents: mixedEventsWithIssues, + frustrations: frustrationList, }); } @@ -301,14 +344,32 @@ export default class Session { }); } + const frustrationEvents = events.filter(ev => { + if (ev.type === TYPES.CLICK || ev.type === TYPES.INPUT) { + // @ts-ignore + return ev.hesitation > 1000 + } + return ev.type === TYPES.CLICKRAGE + } + ) + const frustrationIssues = issuesList.filter(i => i.type === issueTypes.MOUSE_THRASHING) + const frustrationList = [...frustrationEvents, ...frustrationIssues].sort(sortEvents) || []; + + const mixedEventsWithIssues = mergeEventLists( + rawEvents, + frustrationIssues + ).sort(sortEvents) + this.events = events; // @ts-ignore - this.notesWithEvents = rawEvents; + this.notesWithEvents = mixedEventsWithIssues; this.errors = exceptions; this.issues = issuesList; // @ts-ignore legacy code? no idea this.resources = resources; this.stackEvents = stackEventsList; + // @ts-ignore + this.frustrations = frustrationList; return this; } @@ -319,7 +380,7 @@ export default class Session { [...this.notesWithEvents, ...sessionNotes].sort((a, b) => { // @ts-ignore just in case const aTs = a.timestamp || a.time; - // @ts-ignore + // @ts-ignore supporting old code... const bTs = b.timestamp || b.time; return aTs - bTs; diff --git a/frontend/scripts/icons.ts b/frontend/scripts/icons.ts index dab11b224..3a2f9808c 100644 --- a/frontend/scripts/icons.ts +++ b/frontend/scripts/icons.ts @@ -63,12 +63,10 @@ const plugins = (removeFill = true) => { ] } } -// fs.promises.mkdir('/tmp/a/apple', { recursive: true }) -// .then(() => { - fs.writeFileSync(`${UI_DIRNAME}/SVG.tsx`, ` +fs.writeFileSync(`${UI_DIRNAME}/SVG.tsx`, ` import React from 'react'; -export type IconNames = ${icons.map(icon => "'"+ icon.slice(0, -4) + "'").join(' | ')}; +export type IconNames = ${icons.map((icon) => "'"+ icon.slice(0, -4) + "'").join(' | ')}; interface Props { name: IconNames; @@ -88,10 +86,11 @@ ${icons.map(icon => { const { data } = optimize(svg, plugins(canOptimize)); return ` case '${icon.slice(0, -4)}': return ${data.replace(/xlink\:href/g, 'xlinkHref') .replace(/xmlns\:xlink/g, 'xmlnsXlink') - .replace(/clip-path/g, 'clipPath') - .replace(/clip-rule/g, 'clipRule') + .replace(/clip\-path/g, 'clipPath') + .replace(/clip\-rule/g, 'clipRule') // hack to keep fill rule for some icons like stop recording square .replace(/clipRule="evenoddCustomFill"/g, 'clipRule="evenodd" fillRule="evenodd"') + .replace(/fill-rule/g, 'fillRule') .replace(/fill-opacity/g, 'fillOpacity') .replace(/stop-color/g, 'stopColor') diff --git a/mobs/messages.rb b/mobs/messages.rb index c4124226e..99e917778 100644 --- a/mobs/messages.rb +++ b/mobs/messages.rb @@ -446,6 +446,7 @@ message 82, 'PartitionedMessage', :replayer => false do uint 'PartTotal' end +# 90-111 reserved iOS ## Backend-only message 125, 'IssueEvent', :replayer => false, :tracker => false do @@ -465,3 +466,24 @@ message 127, 'SessionSearch', :tracker => false, :replayer => false do uint 'Timestamp' uint 'Partition' end + +# since tracker 4.1.10 + +message 112, 'InputChange', :replayer => false do + uint 'ID' + string 'Value' + boolean 'ValueMasked' + string 'Label' + int 'HesitationTime' + int 'InputDuration' +end + +message 113, 'SelectionChange' do + uint 'SelectionStart' + uint 'SelectionEnd' + string 'Selection' +end + +message 114, 'MouseThrashing' do + uint 'Timestamp' +end \ No newline at end of file diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts index 646360521..8d14740a5 100644 --- a/tracker/tracker-assist/src/Assist.ts +++ b/tracker/tracker-assist/src/Assist.ts @@ -31,9 +31,9 @@ export interface Options { controlConfirm: ConfirmOptions; recordingConfirm: ConfirmOptions; - // @depricated + // @deprecated confirmText?: string; - // @depricated + // @deprecated confirmStyle?: Properties; config: RTCConfiguration; diff --git a/tracker/tracker-fetch/src/index.ts b/tracker/tracker-fetch/src/index.ts index 10b75b7ea..cb9d9e4df 100644 --- a/tracker/tracker-fetch/src/index.ts +++ b/tracker/tracker-fetch/src/index.ts @@ -28,7 +28,7 @@ export interface Options { ignoreHeaders: Array | boolean sanitiser?: (RequestResponseData) => RequestResponseData | null - // Depricated + // @deprecated requestSanitizer?: any responseSanitizer?: any } @@ -49,7 +49,7 @@ export default function(opts: Partial = {}): (app: App | null) => Windo opts, ); if (options.requestSanitizer && options.responseSanitizer) { - console.warn("OpenReplay fetch plugin: `requestSanitizer` and `responseSanitizer` options are depricated. Please, use `sanitiser` instead (check out documentation at https://docs.openreplay.com/plugins/fetch).") + console.warn("OpenReplay fetch plugin: `requestSanitizer` and `responseSanitizer` options are deprecated. Please, use `sanitiser` instead (check out documentation at https://docs.openreplay.com/plugins/fetch).") } return (app: App | null) => { diff --git a/tracker/tracker/src/common/messages.gen.ts b/tracker/tracker/src/common/messages.gen.ts index 8f61ab917..ce48b201d 100644 --- a/tracker/tracker/src/common/messages.gen.ts +++ b/tracker/tracker/src/common/messages.gen.ts @@ -63,6 +63,9 @@ export declare const enum Type { Zustand = 79, BatchMetadata = 81, PartitionedMessage = 82, + InputChange = 112, + SelectionChange = 113, + MouseThrashing = 114, } @@ -490,6 +493,28 @@ export type PartitionedMessage = [ /*partTotal:*/ number, ] +export type InputChange = [ + /*type:*/ Type.InputChange, + /*id:*/ number, + /*value:*/ string, + /*valueMasked:*/ boolean, + /*label:*/ string, + /*hesitationTime:*/ number, + /*inputDuration:*/ number, +] -type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTiming | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage +export type SelectionChange = [ + /*type:*/ Type.SelectionChange, + /*selectionStart:*/ number, + /*selectionEnd:*/ number, + /*selection:*/ string, +] + +export type MouseThrashing = [ + /*type:*/ Type.MouseThrashing, + /*timestamp:*/ number, +] + + +type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTiming | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing export default Message diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index f6d947dd9..faca22b9d 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -149,7 +149,7 @@ export default class App { } }) - // @depricated (use sessionHash on start instead) + // @deprecated (use sessionHash on start instead) if (sessionToken != null) { this.session.applySessionHash(sessionToken) } diff --git a/tracker/tracker/src/main/app/messages.gen.ts b/tracker/tracker/src/main/app/messages.gen.ts index bdec57a0f..dad81cb6a 100644 --- a/tracker/tracker/src/main/app/messages.gen.ts +++ b/tracker/tracker/src/main/app/messages.gen.ts @@ -792,3 +792,44 @@ export function PartitionedMessage( ] } +export function InputChange( + id: number, + value: string, + valueMasked: boolean, + label: string, + hesitationTime: number, + inputDuration: number, +): Messages.InputChange { + return [ + Messages.Type.InputChange, + id, + value, + valueMasked, + label, + hesitationTime, + inputDuration, + ] +} + +export function SelectionChange( + selectionStart: number, + selectionEnd: number, + selection: string, +): Messages.SelectionChange { + return [ + Messages.Type.SelectionChange, + selectionStart, + selectionEnd, + selection, + ] +} + +export function MouseThrashing( + timestamp: number, +): Messages.MouseThrashing { + return [ + Messages.Type.MouseThrashing, + timestamp, + ] +} + diff --git a/tracker/tracker/src/main/app/ticker.ts b/tracker/tracker/src/main/app/ticker.ts index 70bffdf4c..ee05ed93c 100644 --- a/tracker/tracker/src/main/app/ticker.ts +++ b/tracker/tracker/src/main/app/ticker.ts @@ -18,6 +18,12 @@ export default class Ticker { this.callbacks = [] } + /** + * @param {Callback} callback - repeated cb + * @param {number} n - number of turn skips; ticker have a 30 ms cycle + * @param {boolean} useSafe - using safe wrapper to check if app is active + * @param {object} thisArg - link to + * */ attach(callback: Callback, n = 0, useSafe = true, thisArg?: any) { if (thisArg) { callback = callback.bind(thisArg) diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index eae1c867c..0688a22bf 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -24,6 +24,7 @@ import Focus from './modules/focus.js' import Fonts from './modules/fonts.js' import Network from './modules/network.js' import ConstructedStyleSheets from './modules/constructedStyleSheets.js' +import Selection from './modules/selection.js' import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js' import type { Options as AppOptions } from './app/index.js' @@ -131,6 +132,7 @@ export default class API { Focus(app) Fonts(app) Network(app, options.network) + Selection(app) ;(window as any).__OPENREPLAY__ = this if (options.autoResetOnWindowOpen) { diff --git a/tracker/tracker/src/main/modules/input.ts b/tracker/tracker/src/main/modules/input.ts index e2e93bff7..62c059748 100644 --- a/tracker/tracker/src/main/modules/input.ts +++ b/tracker/tracker/src/main/modules/input.ts @@ -1,13 +1,14 @@ import type App from '../app/index.js' -import { normSpaces, IN_BROWSER, getLabelAttribute } from '../utils.js' +import { normSpaces, IN_BROWSER, getLabelAttribute, now } from '../utils.js' import { hasTag } from '../app/guards.js' -import { SetInputTarget, SetInputValue, SetInputChecked } from '../app/messages.gen.js' +import { InputChange, SetInputValue, SetInputChecked } from '../app/messages.gen.js' const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date', 'tel'] // TODO: take into consideration "contenteditable" attribute -type TextEditableElement = HTMLInputElement | HTMLTextAreaElement -function isTextEditable(node: any): node is TextEditableElement { +type TextFieldElement = HTMLInputElement | HTMLTextAreaElement + +function isTextFieldElement(node: Node): node is TextFieldElement { if (hasTag(node, 'textarea')) { return true } @@ -18,7 +19,7 @@ function isTextEditable(node: any): node is TextEditableElement { return INPUT_TYPES.includes(node.type) } -function isCheckable(node: any): node is HTMLInputElement { +function isCheckbox(node: Node): node is HTMLInputElement & { type: 'checkbox' | 'radio' } { if (!hasTag(node, 'input')) { return false } @@ -26,7 +27,7 @@ function isCheckable(node: any): node is HTMLInputElement { return type === 'checkbox' || type === 'radio' } -const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined = +const labelElementFor: (element: TextFieldElement) => HTMLLabelElement | undefined = IN_BROWSER && 'labels' in HTMLInputElement.prototype ? (node) => { let p: Node | null = node @@ -56,7 +57,7 @@ const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | unde } } -export function getInputLabel(node: TextEditableElement): string { +export function getInputLabel(node: TextFieldElement): string { let label = getLabelAttribute(node) if (label === null) { const labelElement = labelElementFor(node) @@ -94,13 +95,8 @@ export default function (app: App, opts: Partial): void { }, opts, ) - function sendInputTarget(id: number, node: TextEditableElement): void { - const label = getInputLabel(node) - if (label !== '') { - app.send(SetInputTarget(id, label)) - } - } - function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void { + + function getInputValue(id: number, node: TextFieldElement | HTMLSelectElement) { let value = node.value let inputMode: InputMode = options.defaultInputMode @@ -127,42 +123,63 @@ export default function (app: App, opts: Partial): void { break } + return { value, mask } + } + function sendInputValue(id: number, node: TextFieldElement | HTMLSelectElement): void { + const { value, mask } = getInputValue(id, node) + app.send(SetInputValue(id, value, mask)) } const inputValues: Map = new Map() - const checkableValues: Map = new Map() - const registeredTargets: Set = new Set() + const checkboxValues: Map = new Map() app.attachStopCallback(() => { inputValues.clear() - checkableValues.clear() - registeredTargets.clear() + checkboxValues.clear() }) - app.ticker.attach((): void => { + function trackInputValue(id: number, node: TextFieldElement) { + if (inputValues.get(id) === node.value) { + return + } + inputValues.set(id, node.value) + sendInputValue(id, node) + } + + function trackCheckboxValue(id: number, value: boolean) { + if (checkboxValues.get(id) === value) { + return + } + checkboxValues.set(id, value) + app.send(SetInputChecked(id, value)) + } + + // The only way (to our knowledge) to track all kinds of input changes, including those made by JS + app.ticker.attach(() => { inputValues.forEach((value, id) => { const node = app.nodes.getNode(id) as HTMLInputElement if (!node) return inputValues.delete(id) - if (value !== node.value) { - inputValues.set(id, node.value) - if (!registeredTargets.has(id)) { - registeredTargets.add(id) - sendInputTarget(id, node) - } - sendInputValue(id, node) - } + trackInputValue(id, node) }) - checkableValues.forEach((checked, id) => { + checkboxValues.forEach((checked, id) => { const node = app.nodes.getNode(id) as HTMLInputElement - if (!node) return checkableValues.delete(id) - if (checked !== node.checked) { - checkableValues.set(id, node.checked) - app.send(SetInputChecked(id, node.checked)) - } + if (!node) return checkboxValues.delete(id) + trackCheckboxValue(id, node.checked) }) - }) - app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets) + }, 3) + + function sendInputChange( + id: number, + node: TextFieldElement, + hesitationTime: number, + inputTime: number, + ) { + const { value, mask } = getInputValue(id, node) + const label = getInputLabel(node) + + app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime)) + } app.nodes.attachNodeCallback( app.safe((node: Node): void => { @@ -170,21 +187,44 @@ export default function (app: App, opts: Partial): void { if (id === undefined) { return } - // TODO: support multiple select (?): use selectedOptions; Need send target? + + // TODO: support multiple select (?): use selectedOptions; if (hasTag(node, 'select')) { sendInputValue(id, node) - app.attachEventListener(node, 'change', () => { - sendInputValue(id, node) - }) + app.nodes.attachNodeListener(node, 'change', () => sendInputValue(id, node)) } - if (isTextEditable(node)) { - inputValues.set(id, node.value) - sendInputValue(id, node) + + if (isTextFieldElement(node)) { + trackInputValue(id, node) + let nodeFocusTime = 0 + let nodeHesitationTime = 0 + let inputTime = 0 + + const onFocus = () => { + nodeFocusTime = now() + } + + const onInput = () => { + if (nodeHesitationTime === 0) { + nodeHesitationTime = now() - nodeFocusTime + } + } + + const onChange = () => { + inputTime = now() - nodeFocusTime + sendInputChange(id, node, nodeHesitationTime, inputTime) + nodeHesitationTime = 0 + inputTime = 0 + } + app.nodes.attachNodeListener(node, 'focus', onFocus) + app.nodes.attachNodeListener(node, 'input', onInput) + app.nodes.attachNodeListener(node, 'change', onChange) return } - if (isCheckable(node)) { - checkableValues.set(id, node.checked) - app.send(SetInputChecked(id, node.checked)) + + if (isCheckbox(node)) { + trackCheckboxValue(id, node.checked) + app.nodes.attachNodeListener(node, 'change', () => trackCheckboxValue(id, node.checked)) return } }), diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index fef7b7754..032792b6f 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -1,7 +1,7 @@ import type App from '../app/index.js' import { hasTag, isSVGElement, isDocument } from '../app/guards.js' -import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils.js' -import { MouseMove, MouseClick } from '../app/messages.gen.js' +import { normSpaces, hasOpenreplayAttribute, getLabelAttribute, now } from '../utils.js' +import { MouseMove, MouseClick, MouseThrashing } from '../app/messages.gen.js' import { getInputLabel } from './input.js' import { finder } from '@medv/finder' @@ -30,7 +30,7 @@ function isClickable(element: Element): boolean { element.getAttribute('role') === 'button' ) //|| element.className.includes("btn") - // MBTODO: intersept addEventListener + // MBTODO: intercept addEventListener } //TODO: fix (typescript is not sure about target variable after assignation of svg) @@ -100,12 +100,46 @@ export default function (app: App): void { let mouseTargetTime = 0 let selectorMap: { [id: number]: string } = {} + let velocity = 0 + let direction = 0 + let directionChangeCount = 0 + let distance = 0 + let checkIntervalId: NodeJS.Timer + const shakeThreshold = 0.008 + const shakeCheckInterval = 225 + + function checkMouseShaking() { + const nextVelocity = distance / shakeCheckInterval + + if (!velocity) { + velocity = nextVelocity + return + } + + const acceleration = (nextVelocity - velocity) / shakeCheckInterval + if (directionChangeCount > 3 && acceleration > shakeThreshold) { + console.log('Mouse shake detected!') + app.send(MouseThrashing(now())) + } + + distance = 0 + directionChangeCount = 0 + velocity = nextVelocity + } + + app.attachStartCallback(() => { + checkIntervalId = setInterval(() => checkMouseShaking(), shakeCheckInterval) + }) + app.attachStopCallback(() => { mousePositionX = -1 mousePositionY = -1 mousePositionChanged = false mouseTarget = null selectorMap = {} + if (checkIntervalId) { + clearInterval(checkIntervalId) + } }) const sendMouseMove = (): void => { @@ -139,6 +173,14 @@ export default function (app: App): 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) + + if (nextDirection !== direction) { + direction = nextDirection + directionChangeCount++ + } }, false, ) diff --git a/tracker/tracker/src/main/modules/selection.ts b/tracker/tracker/src/main/modules/selection.ts new file mode 100644 index 000000000..8a422fedc --- /dev/null +++ b/tracker/tracker/src/main/modules/selection.ts @@ -0,0 +1,39 @@ +import type App from '../app/index.js' +import { SelectionChange } from '../app/messages.gen.js' + +function selection(app: App) { + app.attachEventListener(document, 'selectionchange', () => { + const selection = document.getSelection() + if (selection !== null && !selection.isCollapsed) { + const selectionStart = app.nodes.getID(selection.anchorNode!) + const selectionEnd = app.nodes.getID(selection.focusNode!) + const selectedText = selection.toString().replace(/\s+/g, ' ') + if (selectionStart && selectionEnd) { + app.send(SelectionChange(selectionStart, selectionEnd, selectedText)) + } + } else { + app.send(SelectionChange(-1, -1, '')) + } + }) +} + +export default selection + +/** TODO: research how to get all in-between nodes inside selection range + * including nodes between anchor and focus nodes and their children + * without recursively searching the dom tree + */ + +// if (selection.rangeCount) { +// const nodes = []; +// for (let i = 0; i < selection.rangeCount; i++) { +// const range = selection.getRangeAt(i); +// let node: Node | null = range.startContainer; +// while (node) { +// nodes.push(node); +// if (node === range.endContainer) break; +// node = node.nextSibling; +// } +// } +// // send selected nodes +// } diff --git a/tracker/tracker/src/webworker/MessageEncoder.gen.ts b/tracker/tracker/src/webworker/MessageEncoder.gen.ts index e6e522dd4..06bd776f0 100644 --- a/tracker/tracker/src/webworker/MessageEncoder.gen.ts +++ b/tracker/tracker/src/webworker/MessageEncoder.gen.ts @@ -254,6 +254,18 @@ export default class MessageEncoder extends PrimitiveEncoder { return this.uint(msg[1]) && this.uint(msg[2]) break + case Messages.Type.InputChange: + return this.uint(msg[1]) && this.string(msg[2]) && this.boolean(msg[3]) && this.string(msg[4]) && this.int(msg[5]) && this.int(msg[6]) + break + + case Messages.Type.SelectionChange: + return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) + break + + case Messages.Type.MouseThrashing: + return this.uint(msg[1]) + break + } }