Message protocol update (message size) (#663)

* feat(tracker): update message schema with BatchMetadata; separate message-related responsibilities; add message size
* chore(docker): removing edge busybox, as the main repo updated
* feat(backend): updated message protocol templates
* feat(backend): added support of message size
* feat(backend): implemented iterator for new message protocol (with message size)

Co-authored-by: Alex Kaminskii <alex@openreplay.com>
This commit is contained in:
Alexander 2022-08-22 12:32:48 +02:00 committed by GitHub
parent 8cda6bb1f1
commit 5887ab5ddb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 7016 additions and 4015 deletions

View file

@ -57,7 +57,7 @@ ENV TZ=UTC \
PARTITIONS_NUMBER=16 \ PARTITIONS_NUMBER=16 \
QUEUE_MESSAGE_SIZE_LIMIT=1048576 \ QUEUE_MESSAGE_SIZE_LIMIT=1048576 \
BEACON_SIZE_LIMIT=1000000 \ BEACON_SIZE_LIMIT=1000000 \
USE_FAILOVER=false \ USE_FAILOVER=true \
GROUP_STORAGE_FAILOVER=failover \ GROUP_STORAGE_FAILOVER=failover \
TOPIC_STORAGE_FAILOVER=storage-failover TOPIC_STORAGE_FAILOVER=storage-failover

View file

@ -3,7 +3,7 @@ package main
import ( import (
"context" "context"
"log" "log"
"openreplay/backend/pkg/monitoring" "openreplay/backend/pkg/queue/types"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@ -13,8 +13,8 @@ import (
"openreplay/backend/internal/assets/cacher" "openreplay/backend/internal/assets/cacher"
config "openreplay/backend/internal/config/assets" config "openreplay/backend/internal/config/assets"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
) )
func main() { func main() {
@ -34,22 +34,25 @@ func main() {
consumer := queue.NewMessageConsumer( consumer := queue.NewMessageConsumer(
cfg.GroupCache, cfg.GroupCache,
[]string{cfg.TopicCache}, []string{cfg.TopicCache},
func(sessionID uint64, message messages.Message, e *types.Meta) { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
switch msg := message.(type) { for iter.Next() {
case *messages.AssetCache: if iter.Type() == messages.MsgAssetCache {
cacher.CacheURL(sessionID, msg.URL) msg := iter.Message().Decode().(*messages.AssetCache)
totalAssets.Add(context.Background(), 1) cacher.CacheURL(sessionID, msg.URL)
case *messages.ErrorEvent: totalAssets.Add(context.Background(), 1)
if msg.Source != "js_exception" { } else if iter.Type() == messages.MsgErrorEvent {
return msg := iter.Message().Decode().(*messages.ErrorEvent)
} if msg.Source != "js_exception" {
sourceList, err := assets.ExtractJSExceptionSources(&msg.Payload) continue
if err != nil { }
log.Printf("Error on source extraction: %v", err) sourceList, err := assets.ExtractJSExceptionSources(&msg.Payload)
return if err != nil {
} log.Printf("Error on source extraction: %v", err)
for _, source := range sourceList { continue
cacher.CacheJSFile(source) }
for _, source := range sourceList {
cacher.CacheJSFile(source)
}
} }
} }
}, },

View file

@ -3,24 +3,23 @@ package main
import ( import (
"errors" "errors"
"log" "log"
"openreplay/backend/internal/config/db" "openreplay/backend/pkg/queue/types"
"openreplay/backend/internal/db/datasaver"
"openreplay/backend/pkg/handlers"
custom2 "openreplay/backend/pkg/handlers/custom"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/sessions"
"time"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"openreplay/backend/internal/config/db"
"openreplay/backend/internal/db/datasaver"
"openreplay/backend/pkg/db/cache" "openreplay/backend/pkg/db/cache"
"openreplay/backend/pkg/db/postgres" "openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/handlers"
custom2 "openreplay/backend/pkg/handlers/custom"
logger "openreplay/backend/pkg/log" logger "openreplay/backend/pkg/log"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types" "openreplay/backend/pkg/sessions"
) )
func main() { func main() {
@ -51,49 +50,60 @@ func main() {
saver.InitStats() saver.InitStats()
statsLogger := logger.NewQueueStats(cfg.LoggerTimeout) statsLogger := logger.NewQueueStats(cfg.LoggerTimeout)
keepMessage := func(tp int) bool {
return tp == messages.MsgMetadata || tp == messages.MsgIssueEvent || tp == messages.MsgSessionStart || tp == messages.MsgSessionEnd || tp == messages.MsgUserID || tp == messages.MsgUserAnonymousID || tp == messages.MsgCustomEvent || tp == messages.MsgClickEvent || tp == messages.MsgInputEvent || tp == messages.MsgPageEvent || tp == messages.MsgErrorEvent || tp == messages.MsgFetchEvent || tp == messages.MsgGraphQLEvent || tp == messages.MsgIntegrationEvent || tp == messages.MsgPerformanceTrackAggr || tp == messages.MsgResourceEvent || tp == messages.MsgLongTask || tp == messages.MsgJSException || tp == messages.MsgResourceTiming || tp == messages.MsgRawCustomEvent || tp == messages.MsgCustomIssue || tp == messages.MsgFetch || tp == messages.MsgGraphQL || tp == messages.MsgStateAction || tp == messages.MsgSetInputTarget || tp == messages.MsgSetInputValue || tp == messages.MsgCreateDocument || tp == messages.MsgMouseClick || tp == messages.MsgSetPageLocation || tp == messages.MsgPageLoadTiming || tp == messages.MsgPageRenderTiming
}
// Handler logic // Handler logic
handler := func(sessionID uint64, msg messages.Message, meta *types.Meta) { handler := func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
statsLogger.Collect(sessionID, meta) statsLogger.Collect(sessionID, meta)
// Just save session data into db without additional checks for iter.Next() {
if err := saver.InsertMessage(sessionID, msg); err != nil { if !keepMessage(iter.Type()) {
if !postgres.IsPkeyViolation(err) { continue
log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg)
} }
return msg := iter.Message().Decode()
} log.Printf("process message, type: %d", iter.Type())
session, err := pg.GetSession(sessionID) // Just save session data into db without additional checks
if session == nil {
if err != nil && !errors.Is(err, cache.NilSessionInCacheError) {
log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg)
}
return
}
// Save statistics to db
err = saver.InsertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
}
// Handle heuristics and save to temporary queue in memory
builderMap.HandleMessage(sessionID, msg, msg.Meta().Index)
// Process saved heuristics messages as usual messages above in the code
builderMap.IterateSessionReadyMessages(sessionID, func(msg messages.Message) {
// TODO: DRY code (carefully with the return statement logic)
if err := saver.InsertMessage(sessionID, msg); err != nil { if err := saver.InsertMessage(sessionID, msg); err != nil {
if !postgres.IsPkeyViolation(err) { if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg) log.Printf("Message Insertion Error %v, SessionID: %v, Message: %v", err, sessionID, msg)
} }
return return
} }
if err := saver.InsertStats(session, msg); err != nil { session, err := pg.GetSession(sessionID)
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg) if session == nil {
if err != nil && !errors.Is(err, cache.NilSessionInCacheError) {
log.Printf("Error on session retrieving from cache: %v, SessionID: %v, Message: %v", err, sessionID, msg)
}
return
} }
})
// Save statistics to db
err = saver.InsertStats(session, msg)
if err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message: %v", err, session, msg)
}
// Handle heuristics and save to temporary queue in memory
builderMap.HandleMessage(sessionID, msg, msg.Meta().Index)
// Process saved heuristics messages as usual messages above in the code
builderMap.IterateSessionReadyMessages(sessionID, func(msg messages.Message) {
if err := saver.InsertMessage(sessionID, msg); err != nil {
if !postgres.IsPkeyViolation(err) {
log.Printf("Message Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
return
}
if err := saver.InsertStats(session, msg); err != nil {
log.Printf("Stats Insertion Error %v; Session: %v, Message %v", err, session, msg)
}
})
}
} }
// Init consumer // Init consumer

View file

@ -2,25 +2,23 @@ package main
import ( import (
"log" "log"
"openreplay/backend/pkg/queue/types"
"os"
"os/signal"
"syscall"
"time"
"openreplay/backend/internal/config/ender" "openreplay/backend/internal/config/ender"
"openreplay/backend/internal/sessionender" "openreplay/backend/internal/sessionender"
"openreplay/backend/pkg/db/cache" "openreplay/backend/pkg/db/cache"
"openreplay/backend/pkg/db/postgres" "openreplay/backend/pkg/db/postgres"
"openreplay/backend/pkg/monitoring"
"time"
"os"
"os/signal"
"syscall"
"openreplay/backend/pkg/intervals" "openreplay/backend/pkg/intervals"
logger "openreplay/backend/pkg/log" logger "openreplay/backend/pkg/log"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
) )
//
func main() { func main() {
metrics := monitoring.New("ender") metrics := monitoring.New("ender")
@ -45,18 +43,17 @@ func main() {
[]string{ []string{
cfg.TopicRawWeb, cfg.TopicRawWeb,
}, },
func(sessionID uint64, msg messages.Message, meta *types.Meta) { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
switch msg.(type) { for iter.Next() {
case *messages.SessionStart, *messages.SessionEnd: if iter.Type() == messages.MsgSessionStart || iter.Type() == messages.MsgSessionEnd {
// Skip several message types continue
return }
if iter.Message().Meta().Timestamp == 0 {
log.Printf("ZERO TS, sessID: %d, msgType: %d", sessionID, iter.Type())
}
statsLogger.Collect(sessionID, meta)
sessions.UpdateSession(sessionID, meta.Timestamp, iter.Message().Meta().Timestamp)
} }
// Test debug
if msg.Meta().Timestamp == 0 {
log.Printf("ZERO TS, sessID: %d, msgType: %d", sessionID, msg.TypeID())
}
statsLogger.Collect(sessionID, meta)
sessions.UpdateSession(sessionID, meta.Timestamp, msg.Meta().Timestamp)
}, },
false, false,
cfg.MessageSizeLimit, cfg.MessageSizeLimit,

View file

@ -2,6 +2,12 @@ package main
import ( import (
"log" "log"
"openreplay/backend/pkg/queue/types"
"os"
"os/signal"
"syscall"
"time"
"openreplay/backend/internal/config/heuristics" "openreplay/backend/internal/config/heuristics"
"openreplay/backend/pkg/handlers" "openreplay/backend/pkg/handlers"
web2 "openreplay/backend/pkg/handlers/web" web2 "openreplay/backend/pkg/handlers/web"
@ -9,12 +15,7 @@ import (
logger "openreplay/backend/pkg/log" logger "openreplay/backend/pkg/log"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
"openreplay/backend/pkg/sessions" "openreplay/backend/pkg/sessions"
"os"
"os/signal"
"syscall"
"time"
) )
func main() { func main() {
@ -55,9 +56,11 @@ func main() {
[]string{ []string{
cfg.TopicRawWeb, cfg.TopicRawWeb,
}, },
func(sessionID uint64, msg messages.Message, meta *types.Meta) { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
statsLogger.Collect(sessionID, meta) for iter.Next() {
builderMap.HandleMessage(sessionID, msg, msg.Meta().Index) statsLogger.Collect(sessionID, meta)
builderMap.HandleMessage(sessionID, iter.Message().Decode(), iter.Message().Meta().Index)
}
}, },
false, false,
cfg.MessageSizeLimit, cfg.MessageSizeLimit,

View file

@ -2,22 +2,20 @@ package main
import ( import (
"context" "context"
"encoding/binary"
"log" "log"
"openreplay/backend/internal/sink/assetscache" "openreplay/backend/pkg/queue/types"
"openreplay/backend/internal/sink/oswriter"
"openreplay/backend/internal/storage"
"openreplay/backend/pkg/monitoring"
"time"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"openreplay/backend/internal/config/sink" "openreplay/backend/internal/config/sink"
"openreplay/backend/internal/sink/assetscache"
"openreplay/backend/internal/sink/oswriter"
"openreplay/backend/internal/storage"
. "openreplay/backend/pkg/messages" . "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
"openreplay/backend/pkg/url/assets" "openreplay/backend/pkg/url/assets"
) )
@ -58,51 +56,49 @@ func main() {
[]string{ []string{
cfg.TopicRawWeb, cfg.TopicRawWeb,
}, },
func(sessionID uint64, message Message, _ *types.Meta) { func(sessionID uint64, iter Iterator, meta *types.Meta) {
// Process assets for iter.Next() {
message = assetMessageHandler.ParseAssets(sessionID, message) // [METRICS] Increase the number of processed messages
totalMessages.Add(context.Background(), 1)
totalMessages.Add(context.Background(), 1) // Send SessionEnd trigger to storage service
if iter.Type() == MsgSessionEnd {
// Filter message if err := producer.Produce(cfg.TopicTrigger, sessionID, iter.Message().Encode()); err != nil {
typeID := message.TypeID() log.Printf("can't send SessionEnd to trigger topic: %s; sessID: %d", err, sessionID)
}
// Send SessionEnd trigger to storage service continue
switch message.(type) {
case *SessionEnd:
if err := producer.Produce(cfg.TopicTrigger, sessionID, Encode(message)); err != nil {
log.Printf("can't send SessionEnd to trigger topic: %s; sessID: %d", err, sessionID)
} }
return
}
if !IsReplayerType(typeID) {
return
}
// If message timestamp is empty, use at least ts of session start msg := iter.Message()
ts := message.Meta().Timestamp // Process assets
if ts == 0 { if iter.Type() == MsgSetNodeAttributeURLBased || iter.Type() == MsgSetCSSDataURLBased || iter.Type() == MsgCSSInsertRuleURLBased {
log.Printf("zero ts; sessID: %d, msg: %+v", sessionID, message) msg = assetMessageHandler.ParseAssets(sessionID, msg.Decode())
} else { }
// Log ts of last processed message
counter.Update(sessionID, time.UnixMilli(ts))
}
value := message.Encode() // Filter message
var data []byte if !IsReplayerType(msg.TypeID()) {
if IsIOSType(typeID) { continue
data = value }
} else {
data = make([]byte, len(value)+8)
copy(data[8:], value[:])
binary.LittleEndian.PutUint64(data[0:], message.Meta().Index)
}
if err := writer.Write(sessionID, data); err != nil {
log.Printf("Writer error: %v\n", err)
}
messageSize.Record(context.Background(), float64(len(data))) // If message timestamp is empty, use at least ts of session start
savedMessages.Add(context.Background(), 1) ts := msg.Meta().Timestamp
if ts == 0 {
log.Printf("zero ts; sessID: %d, msgType: %d", sessionID, iter.Type())
} else {
// Log ts of last processed message
counter.Update(sessionID, time.UnixMilli(ts))
}
// Write encoded message with index to session file
data := msg.EncodeWithIndex()
if err := writer.Write(sessionID, data); err != nil {
log.Printf("Writer error: %v\n", err)
}
// [METRICS] Increase the number of written to the files messages and the message size
messageSize.Record(context.Background(), float64(len(data)))
savedMessages.Add(context.Background(), 1)
}
}, },
false, false,
cfg.MessageSizeLimit, cfg.MessageSizeLimit,

View file

@ -2,8 +2,7 @@ package main
import ( import (
"log" "log"
"openreplay/backend/pkg/failover" "openreplay/backend/pkg/queue/types"
"openreplay/backend/pkg/monitoring"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
@ -12,9 +11,10 @@ import (
config "openreplay/backend/internal/config/storage" config "openreplay/backend/internal/config/storage"
"openreplay/backend/internal/storage" "openreplay/backend/internal/storage"
"openreplay/backend/pkg/failover"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/monitoring"
"openreplay/backend/pkg/queue" "openreplay/backend/pkg/queue"
"openreplay/backend/pkg/queue/types"
s3storage "openreplay/backend/pkg/storage" s3storage "openreplay/backend/pkg/storage"
) )
@ -43,14 +43,17 @@ func main() {
[]string{ []string{
cfg.TopicTrigger, cfg.TopicTrigger,
}, },
func(sessionID uint64, msg messages.Message, meta *types.Meta) { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
switch m := msg.(type) { for iter.Next() {
case *messages.SessionEnd: if iter.Type() == messages.MsgSessionEnd {
if err := srv.UploadKey(strconv.FormatUint(sessionID, 10), 5); err != nil { msg := iter.Message().Decode().(*messages.SessionEnd)
sessionFinder.Find(sessionID, m.Timestamp) if err := srv.UploadKey(strconv.FormatUint(sessionID, 10), 5); err != nil {
log.Printf("can't find session: %d", sessionID)
sessionFinder.Find(sessionID, msg.Timestamp)
}
// Log timestamp of last processed session
counter.Update(sessionID, time.UnixMilli(meta.Timestamp))
} }
// Log timestamp of last processed session
counter.Update(sessionID, time.UnixMilli(meta.Timestamp))
} }
}, },
true, true,

View file

@ -71,6 +71,7 @@ func (w *Writer) Write(key uint64, data []byte) error {
if err != nil { if err != nil {
return err return err
} }
// TODO: add check for the number of recorded bytes to file
_, err = file.Write(data) _, err = file.Write(data)
return err return err
} }

View file

@ -1,67 +1,153 @@
package messages package messages
import ( import (
"fmt" "bytes"
"github.com/pkg/errors"
"io" "io"
"log"
"strings" "strings"
) )
func ReadBatchReader(reader io.Reader, messageHandler func(Message)) error { type Iterator interface {
var index uint64 Next() bool // Return true if we have next message
var timestamp int64 Type() int // Return type of the next message
Message() Message // Return raw or decoded message
}
for { type iteratorImpl struct {
msg, err := ReadMessage(reader) data *bytes.Reader
index uint64
timestamp int64
version uint64
msgType uint64
msgSize uint64
canSkip bool
msg Message
}
func NewIterator(data []byte) Iterator {
return &iteratorImpl{
data: bytes.NewReader(data),
}
}
func (i *iteratorImpl) Next() bool {
if i.canSkip {
if _, err := i.data.Seek(int64(i.msgSize), io.SeekCurrent); err != nil {
log.Printf("seek err: %s", err)
return false
}
}
i.canSkip = false
var err error
i.msgType, err = ReadUint(i.data)
if err != nil {
if err == io.EOF { if err == io.EOF {
return nil return false
}
log.Printf("can't read message type: %s", err)
return false
}
if i.version > 0 && messageHasSize(i.msgType) {
// Read message size if it is a new protocol version
i.msgSize, err = ReadSize(i.data)
if err != nil {
log.Printf("can't read message size: %s", err)
return false
}
i.msg = &RawMessage{
tp: i.msgType,
size: i.msgSize,
meta: &message{},
reader: i.data,
skipped: &i.canSkip,
}
i.canSkip = true
} else {
i.msg, err = ReadMessage(i.msgType, i.data)
if err == io.EOF {
return false
} else if err != nil { } else if err != nil {
if strings.HasPrefix(err.Error(), "Unknown message code:") { if strings.HasPrefix(err.Error(), "Unknown message code:") {
code := strings.TrimPrefix(err.Error(), "Unknown message code: ") code := strings.TrimPrefix(err.Error(), "Unknown message code: ")
msg, err = DecodeExtraMessage(code, reader) i.msg, err = DecodeExtraMessage(code, i.data)
if err != nil { if err != nil {
return fmt.Errorf("can't decode msg: %s", err) log.Printf("can't decode msg: %s", err)
return false
} }
} else { } else {
return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index) log.Printf("Batch Message decoding error on message with index %v, err: %s", i.index, err)
return false
} }
} }
msg = transformDeprecated(msg) i.msg = transformDeprecated(i.msg)
isBatchMeta := false
switch m := msg.(type) {
case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)
if index != 0 { // Might be several 0-0 BatchMeta in a row without a error though
return errors.New("Batch Meta found at the end of the batch")
}
index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha)
timestamp = m.Timestamp
isBatchMeta = true
// continue readLoop
case *IOSBatchMeta:
if index != 0 { // Might be several 0-0 BatchMeta in a row without a error though
return errors.New("Batch Meta found at the end of the batch")
}
index = m.FirstIndex
timestamp = int64(m.Timestamp)
isBatchMeta = true
// continue readLoop
case *Timestamp:
timestamp = int64(m.Timestamp) // TODO(?): replace timestamp type to int64 everywhere (including encoding part in tracker)
// No skipping here for making it easy to encode back the same sequence of message
// continue readLoop
case *SessionStart:
timestamp = int64(m.Timestamp)
case *SessionEnd:
timestamp = int64(m.Timestamp)
}
msg.Meta().Index = index
msg.Meta().Timestamp = timestamp
messageHandler(msg)
if !isBatchMeta { // Without that indexes will be unique anyway, though shifted by 1 because BatchMeta is not counted in tracker
index++
}
} }
return errors.New("Error of the codeflow. (Should return on EOF)")
// Process meta information
isBatchMeta := false
switch i.msgType {
case MsgBatchMetadata:
if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though
log.Printf("Batch Meta found at the end of the batch")
return false
}
m := i.msg.Decode().(*BatchMetadata)
i.index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha)
i.timestamp = m.Timestamp
i.version = m.Version
isBatchMeta = true
log.Printf("new batch version: %d", i.version)
case MsgBatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)
if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though
log.Printf("Batch Meta found at the end of the batch")
return false
}
m := i.msg.Decode().(*BatchMeta)
i.index = m.PageNo<<32 + m.FirstIndex // 2^32 is the maximum count of messages per page (ha-ha)
i.timestamp = m.Timestamp
isBatchMeta = true
// continue readLoop
case MsgIOSBatchMeta:
if i.index != 0 { // Might be several 0-0 BatchMeta in a row without an error though
log.Printf("Batch Meta found at the end of the batch")
return false
}
m := i.msg.Decode().(*IOSBatchMeta)
i.index = m.FirstIndex
i.timestamp = int64(m.Timestamp)
isBatchMeta = true
// continue readLoop
case MsgTimestamp:
m := i.msg.Decode().(*Timestamp)
i.timestamp = int64(m.Timestamp)
// No skipping here for making it easy to encode back the same sequence of message
// continue readLoop
case MsgSessionStart:
m := i.msg.Decode().(*SessionStart)
i.timestamp = int64(m.Timestamp)
case MsgSessionEnd:
m := i.msg.Decode().(*SessionEnd)
i.timestamp = int64(m.Timestamp)
}
i.msg.Meta().Index = i.index
i.msg.Meta().Timestamp = i.timestamp
if !isBatchMeta { // Without that indexes will be unique anyway, though shifted by 1 because BatchMeta is not counted in tracker
i.index++
}
return true
}
func (i *iteratorImpl) Type() int {
return int(i.msgType)
}
func (i *iteratorImpl) Message() Message {
return i.msg
}
func messageHasSize(msgType uint64) bool {
return !(msgType == 80 || msgType == 81 || msgType == 82)
} }

View file

@ -1,6 +1,7 @@
package messages package messages
import ( import (
"encoding/binary"
"fmt" "fmt"
"io" "io"
) )
@ -20,6 +21,21 @@ func (msg *SessionSearch) Encode() []byte {
return buf[:p] return buf[:p]
} }
func (msg *SessionSearch) EncodeWithIndex() []byte {
encoded := msg.Encode()
if IsIOSType(msg.TypeID()) {
return encoded
}
data := make([]byte, len(encoded)+8)
copy(data[8:], encoded[:])
binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index)
return data
}
func (msg *SessionSearch) Decode() Message {
return msg
}
func (msg *SessionSearch) TypeID() int { func (msg *SessionSearch) TypeID() int {
return 127 return 127
} }

View file

@ -16,6 +16,8 @@ func (m *message) SetMeta(origin *message) {
type Message interface { type Message interface {
Encode() []byte Encode() []byte
EncodeWithIndex() []byte
Decode() Message
TypeID() int TypeID() int
Meta() *message Meta() *message
} }

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ package messages
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"log" "log"
) )
@ -16,15 +17,6 @@ func ReadByte(reader io.Reader) (byte, error) {
return p[0], nil return p[0], nil
} }
// func SkipBytes(reader io.ReadSeeker) error {
// n, err := ReadUint(reader)
// if err != nil {
// return err
// }
// _, err = reader.Seek(n, io.SeekCurrent);
// return err
// }
func ReadData(reader io.Reader) ([]byte, error) { func ReadData(reader io.Reader) ([]byte, error) {
n, err := ReadUint(reader) n, err := ReadUint(reader)
if err != nil { if err != nil {
@ -153,3 +145,28 @@ func WriteJson(v interface{}, buf []byte, p int) int {
} }
return WriteData(data, buf, p) return WriteData(data, buf, p)
} }
func WriteSize(size uint64, buf []byte, p int) {
var m uint64 = 255
for i := 0; i < 3; i++ {
buf[p+i] = byte(size & m)
size = size >> 8
}
fmt.Println(buf)
}
func ReadSize(reader io.Reader) (uint64, error) {
buf := make([]byte, 3)
n, err := io.ReadFull(reader, buf)
if err != nil {
return 0, err
}
if n != 3 {
return 0, fmt.Errorf("read only %d of 3 size bytes", n)
}
var size uint64
for i, b := range buf {
size += uint64(b) << (8 * i)
}
return size, nil
}

View file

@ -0,0 +1,69 @@
package messages
import (
"bytes"
"encoding/binary"
"io"
"log"
)
// RawMessage is a not decoded message
type RawMessage struct {
tp uint64
size uint64
data []byte
reader *bytes.Reader
meta *message
encoded bool
skipped *bool
}
func (m *RawMessage) Encode() []byte {
if m.encoded {
return m.data
}
m.data = make([]byte, m.size+1)
m.data[0] = uint8(m.tp)
m.encoded = true
*m.skipped = false
n, err := io.ReadFull(m.reader, m.data[1:])
if err != nil {
log.Printf("message encode err: %s", err)
return nil
}
log.Printf("encode: read %d of %d bytes", n, m.size)
return m.data
}
func (m *RawMessage) EncodeWithIndex() []byte {
if !m.encoded {
m.Encode()
}
if IsIOSType(int(m.tp)) {
return m.data
}
data := make([]byte, len(m.data)+8)
copy(data[8:], m.data[:])
binary.LittleEndian.PutUint64(data[0:], m.Meta().Index)
return data
}
func (m *RawMessage) Decode() Message {
if !m.encoded {
m.Encode()
}
msg, err := ReadMessage(m.tp, bytes.NewReader(m.data[1:]))
if err != nil {
log.Printf("decode err: %s", err)
}
msg.Meta().SetMeta(m.meta)
return msg
}
func (m *RawMessage) TypeID() int {
return int(m.tp)
}
func (m *RawMessage) Meta() *message {
return m.meta
}

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,12 @@
package queue package queue
import ( import (
"bytes"
"log"
"openreplay/backend/pkg/messages" "openreplay/backend/pkg/messages"
"openreplay/backend/pkg/queue/types" "openreplay/backend/pkg/queue/types"
) )
func NewMessageConsumer(group string, topics []string, handler types.DecodedMessageHandler, autoCommit bool, messageSizeLimit int) types.Consumer { func NewMessageConsumer(group string, topics []string, handler types.RawMessageHandler, autoCommit bool, messageSizeLimit int) types.Consumer {
return NewConsumer(group, topics, func(sessionID uint64, value []byte, meta *types.Meta) { return NewConsumer(group, topics, func(sessionID uint64, value []byte, meta *types.Meta) {
if err := messages.ReadBatchReader(bytes.NewReader(value), func(msg messages.Message) { handler(sessionID, messages.NewIterator(value), meta)
handler(sessionID, msg, meta)
}); err != nil {
log.Printf("Decode error: %v\n", err)
}
}, autoCommit, messageSizeLimit) }, autoCommit, messageSizeLimit)
} }

View file

@ -26,3 +26,4 @@ type Meta struct {
type MessageHandler func(uint64, []byte, *Meta) type MessageHandler func(uint64, []byte, *Meta)
type DecodedMessageHandler func(uint64, messages.Message, *Meta) type DecodedMessageHandler func(uint64, messages.Message, *Meta)
type RawMessageHandler func(uint64, messages.Iterator, *Meta)

View file

@ -60,10 +60,12 @@ func NewSessionFinder(cfg *config.Config, stg *storage.Storage) (SessionFinder,
[]string{ []string{
cfg.TopicFailover, cfg.TopicFailover,
}, },
func(sessionID uint64, msg messages.Message, meta *types.Meta) { func(sessionID uint64, iter messages.Iterator, meta *types.Meta) {
switch m := msg.(type) { for iter.Next() {
case *messages.SessionSearch: if iter.Type() == 127 {
finder.findSession(sessionID, m.Timestamp, m.Partition) m := iter.Message().Decode().(*messages.SessionSearch)
finder.findSession(sessionID, m.Timestamp, m.Partition)
}
} }
}, },
true, true,

View file

@ -1,13 +1,31 @@
# Special one for Batch Meta. Message id could define the version # Special one for Batch Meta. Message id could define the version
message 80, 'BatchMeta', :replayer => false do # Depricated since tracker 3.6.0 in favor of BatchMetadata
message 80, 'BatchMeta', :tracker => false, :replayer => false do
uint 'PageNo' uint 'PageNo'
uint 'FirstIndex' uint 'FirstIndex'
int 'Timestamp' int 'Timestamp'
end end
# since tracker 3.6.0
message 81, 'BatchMetadata', :replayer => false do
uint 'Version'
uint 'PageNo'
uint 'FirstIndex'
int 'Timestamp'
string 'Location'
end
# since tracker 3.6.0
message 82, 'PartitionedMessage', :replayer => false do
uint 'PartNo'
uint 'PartTotal'
end
message 0, 'Timestamp' do message 0, 'Timestamp' do
uint 'Timestamp' uint 'Timestamp'
end end
message 1, 'SessionStart', :js => false, :replayer => false do message 1, 'SessionStart', :tracker => false, :replayer => false do
uint 'Timestamp' uint 'Timestamp'
uint 'ProjectID' uint 'ProjectID'
string 'TrackerVersion' string 'TrackerVersion'
@ -26,10 +44,10 @@ message 1, 'SessionStart', :js => false, :replayer => false do
string 'UserID' string 'UserID'
end end
# Depricated (not used) since OpenReplay tracker 3.0.0 # Depricated (not used) since OpenReplay tracker 3.0.0
message 2, 'SessionDisconnect', :js => false do message 2, 'SessionDisconnect', :tracker => false do
uint 'Timestamp' uint 'Timestamp'
end end
message 3, 'SessionEnd', :js => false, :replayer => false do message 3, 'SessionEnd', :tracker => false, :replayer => false do
uint 'Timestamp' uint 'Timestamp'
end end
message 4, 'SetPageLocation' do message 4, 'SetPageLocation' do
@ -80,8 +98,7 @@ message 14, 'SetNodeData' do
uint 'ID' uint 'ID'
string 'Data' string 'Data'
end end
# Depricated starting from 5.5.11 in favor of SetStyleData message 15, 'SetCSSData', :tracker => false do
message 15, 'SetCSSData', :js => false do
uint 'ID' uint 'ID'
string 'Data' string 'Data'
end end
@ -108,7 +125,7 @@ message 20, 'MouseMove' do
uint 'Y' uint 'Y'
end end
# Depricated since OpenReplay 1.2.0 # Depricated since OpenReplay 1.2.0
message 21, 'MouseClickDepricated', :js => false, :replayer => false do message 21, 'MouseClickDepricated', :tracker => false, :replayer => false do
uint 'ID' uint 'ID'
uint 'HesitationTime' uint 'HesitationTime'
string 'Label' string 'Label'
@ -138,7 +155,7 @@ message 25, 'JSException', :replayer => false do
string 'Message' string 'Message'
string 'Payload' string 'Payload'
end end
message 26, 'IntegrationEvent', :js => false, :replayer => false do message 26, 'IntegrationEvent', :tracker => false, :replayer => false do
uint 'Timestamp' uint 'Timestamp'
string 'Source' string 'Source'
string 'Name' string 'Name'
@ -159,7 +176,7 @@ message 30, 'Metadata', :replayer => false do
string 'Key' string 'Key'
string 'Value' string 'Value'
end end
message 31, 'PageEvent', :js => false, :replayer => false do message 31, 'PageEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'URL' string 'URL'
@ -178,21 +195,21 @@ message 31, 'PageEvent', :js => false, :replayer => false do
uint 'VisuallyComplete' uint 'VisuallyComplete'
uint 'TimeToInteractive' uint 'TimeToInteractive'
end end
message 32, 'InputEvent', :js => false, :replayer => false do message 32, 'InputEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Value' string 'Value'
boolean 'ValueMasked' boolean 'ValueMasked'
string 'Label' string 'Label'
end end
message 33, 'ClickEvent', :js => false, :replayer => false do message 33, 'ClickEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
uint 'HesitationTime' uint 'HesitationTime'
string 'Label' string 'Label'
string 'Selector' string 'Selector'
end end
message 34, 'ErrorEvent', :js => false, :replayer => false do message 34, 'ErrorEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Source' string 'Source'
@ -200,7 +217,7 @@ message 34, 'ErrorEvent', :js => false, :replayer => false do
string 'Message' string 'Message'
string 'Payload' string 'Payload'
end end
message 35, 'ResourceEvent', :js => false, :replayer => false do message 35, 'ResourceEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
uint 'Duration' uint 'Duration'
@ -214,7 +231,7 @@ message 35, 'ResourceEvent', :js => false, :replayer => false do
string 'Method' string 'Method'
uint 'Status' uint 'Status'
end end
message 36, 'CustomEvent', :js => false, :replayer => false do message 36, 'CustomEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Name' string 'Name'
@ -255,7 +272,7 @@ end
message 42, 'StateAction', :replayer => false do message 42, 'StateAction', :replayer => false do
string 'Type' string 'Type'
end end
message 43, 'StateActionEvent', :js => false, :replayer => false do message 43, 'StateActionEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Type' string 'Type'
@ -291,7 +308,7 @@ message 49, 'PerformanceTrack' do
uint 'TotalJSHeapSize' uint 'TotalJSHeapSize'
uint 'UsedJSHeapSize' uint 'UsedJSHeapSize'
end end
message 50, 'GraphQLEvent', :js => false, :replayer => false do message 50, 'GraphQLEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'OperationKind' string 'OperationKind'
@ -299,7 +316,7 @@ message 50, 'GraphQLEvent', :js => false, :replayer => false do
string 'Variables' string 'Variables'
string 'Response' string 'Response'
end end
message 51, 'FetchEvent', :js => false, :replayer => false do message 51, 'FetchEvent', :tracker => false, :replayer => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Method' string 'Method'
@ -309,7 +326,7 @@ message 51, 'FetchEvent', :js => false, :replayer => false do
uint 'Status' uint 'Status'
uint 'Duration' uint 'Duration'
end end
message 52, 'DOMDrop', :js => false, :replayer => false do message 52, 'DOMDrop', :tracker => false, :replayer => false do
uint 'Timestamp' uint 'Timestamp'
end end
message 53, 'ResourceTiming', :replayer => false do message 53, 'ResourceTiming', :replayer => false do
@ -329,7 +346,7 @@ end
message 55, 'SetPageVisibility' do message 55, 'SetPageVisibility' do
boolean 'hidden' boolean 'hidden'
end end
message 56, 'PerformanceTrackAggr', :js => false, :replayer => false do message 56, 'PerformanceTrackAggr', :tracker => false, :replayer => false do
uint 'TimestampStart' uint 'TimestampStart'
uint 'TimestampEnd' uint 'TimestampEnd'
uint 'MinFPS' uint 'MinFPS'
@ -366,7 +383,7 @@ message 61, 'SetCSSDataURLBased', :replayer => false do
string 'Data' string 'Data'
string 'BaseURL' string 'BaseURL'
end end
message 62, 'IssueEvent', :replayer => false, :js => false do message 62, 'IssueEvent', :replayer => false, :tracker => false do
uint 'MessageID' uint 'MessageID'
uint 'Timestamp' uint 'Timestamp'
string 'Type' string 'Type'
@ -382,11 +399,7 @@ message 64, 'CustomIssue', :replayer => false do
string 'Name' string 'Name'
string 'Payload' string 'Payload'
end end
# Since 5.6.6; only for websocket (might be probably replaced with ws.close()) message 66, 'AssetCache', :replayer => false, :tracker => false do
# Depricated
message 65, 'PageClose', :replayer => false do
end
message 66, 'AssetCache', :replayer => false, :js => false do
string 'URL' string 'URL'
end end
message 67, 'CSSInsertRuleURLBased', :replayer => false do message 67, 'CSSInsertRuleURLBased', :replayer => false do

View file

@ -19,7 +19,7 @@ func SkipBytes(reader io.ReadSeeker) error {
if err != nil { if err != nil {
return err return err
} }
_, err := reader.Seek(n, io.SeekCurrent); _, err = reader.Seek(int64(n), io.SeekCurrent)
return err return err
} }
@ -29,13 +29,13 @@ func ReadData(reader io.Reader) ([]byte, error) {
return nil, err return nil, err
} }
p := make([]byte, n) p := make([]byte, n)
_, err := io.ReadFull(reader, p) _, err = io.ReadFull(reader, p)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return p, nil return p, nil
} }
func ReadUint(reader io.Reader) (uint64, error) { func ReadUint(reader io.Reader) (uint64, error) {
var x uint64 var x uint64
var s uint var s uint

View file

@ -3,24 +3,23 @@ require 'erb'
# TODO: change method names to correct (CapitalCase and camelCase, not CamalCase and firstLower) # TODO: change method names to correct (CapitalCase and camelCase, not CamalCase and firstLower)
class String class String
def camel_case def upperize_abbreviations
return self if self !~ /_/ && self =~ /[A-Z]+.*/
split('_').map{|e| e.capitalize}.join.upperize
end
def camel_case_lower
self.split('_').inject([]){ |buffer,e| buffer.push(buffer.empty? ? e : e.capitalize) }.join.upperize
end
def upperize
self.sub('Id', 'ID').sub('Url', 'URL') self.sub('Id', 'ID').sub('Url', 'URL')
end end
def first_lower # pascal_case
def pascal_case
return self if self !~ /_/ && self =~ /[A-Z]+.*/
split('_').map{|e| e.capitalize}.join.upperize_abbreviations
end
# camelCase
def camel_case
self.sub(/^[A-Z]+/) {|f| f.downcase } self.sub(/^[A-Z]+/) {|f| f.downcase }
end end
def underscore # snake_case
def snake_case
self.gsub(/::/, '/'). self.gsub(/::/, '/').
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
gsub(/([a-z\d])([A-Z])/,'\1_\2'). gsub(/([a-z\d])([A-Z])/,'\1_\2').
@ -85,16 +84,17 @@ end
$context = :web $context = :web
class Message class Message
attr_reader :id, :name, :js, :replayer, :swift, :seq_index, :attributes, :context attr_reader :id, :name, :tracker, :replayer, :swift, :seq_index, :attributes, :context
def initialize(name:, id:, js: $context == :web, replayer: $context == :web, swift: $context == :ios, seq_index: false, &block) def initialize(name:, id:, tracker: $context == :web, replayer: $context == :web, swift: $context == :ios, seq_index: false, &block)
@id = id @id = id
@name = name @name = name
@js = js @tracker = tracker
@replayer = replayer @replayer = replayer
@swift = swift @swift = swift
@seq_index = seq_index @seq_index = seq_index
@context = $context @context = $context
@attributes = [] @attributes = []
# opts.each { |key, value| send "#{key}=", value }
instance_eval &block instance_eval &block
end end
@ -131,6 +131,7 @@ Dir["templates/*.erb"].each do |tpl|
path = tpl.split '/' path = tpl.split '/'
t = '../' + path[1].gsub('~', '/') t = '../' + path[1].gsub('~', '/')
t = t[0..-5] t = t[0..-5]
# TODO: .gen subextention
File.write(t, e.result) File.write(t, e.result)
puts tpl + ' --> ' + t puts tpl + ' --> ' + t
end end

View file

@ -1,5 +1,12 @@
// Auto-generated, do not edit // Auto-generated, do not edit
package messages package messages
const (
<% $messages.each do |msg| %>
Msg<%= msg.name %> = <%= msg.id %>
<% end %>
)
<% $messages.each do |msg| %> <% $messages.each do |msg| %>
type <%= msg.name %> struct { type <%= msg.name %> struct {
message message
@ -12,10 +19,25 @@ func (msg *<%= msg.name %>) Encode() []byte {
buf[0] = <%= msg.id %> buf[0] = <%= msg.id %>
p := 1 p := 1
<%= msg.attributes.map { |attr| <%= msg.attributes.map { |attr|
" p = Write#{attr.type.to_s.camel_case}(msg.#{attr.name}, buf, p)" }.join "\n" %> " p = Write#{attr.type.to_s.pascal_case}(msg.#{attr.name}, buf, p)" }.join "\n" %>
return buf[:p] return buf[:p]
} }
func (msg *<%= msg.name %>) EncodeWithIndex() []byte {
encoded := msg.Encode()
if IsIOSType(msg.TypeID()) {
return encoded
}
data := make([]byte, len(encoded)+8)
copy(data[8:], encoded[:])
binary.LittleEndian.PutUint64(data[0:], msg.Meta().Index)
return data
}
func (msg *<%= msg.name %>) Decode() Message {
return msg
}
func (msg *<%= msg.name %>) TypeID() int { func (msg *<%= msg.name %>) TypeID() int {
return <%= msg.id %> return <%= msg.id %>
} }

View file

@ -6,20 +6,24 @@ import (
"io" "io"
) )
func ReadMessage(reader io.Reader) (Message, error) { <% $messages.each do |msg| %>
t, err := ReadUint(reader) func Decode<%= msg.name %>(reader io.Reader) (Message, error) {
if err != nil { var err error = nil
return nil, err msg := &<%= msg.name %>{}
} <%= msg.attributes.map { |attr|
" if msg.#{attr.name}, err = Read#{attr.type.to_s.pascal_case}(reader); err != nil {
return nil, err
}" }.join "\n" %>
return msg, err
}
<% end %>
func ReadMessage(t uint64, reader io.Reader) (Message, error) {
switch t { switch t {
<% $messages.each do |msg| %> <% $messages.each do |msg| %>
case <%= msg.id %>: case <%= msg.id %>:
msg := &<%= msg.name %>{} return Decode<%= msg.name %>(reader)
<%= msg.attributes.map { |attr|
" if msg.#{attr.name}, err = Read#{attr.type.to_s.camel_case}(reader); err != nil {
return nil, err
}" }.join "\n" %>
return msg, nil
<% end %> <% end %>
} }
return nil, fmt.Errorf("Unknown message code: %v", t) return nil, fmt.Errorf("Unknown message code: %v", t)

View file

@ -9,8 +9,8 @@ class Message(ABC):
class <%= msg.name %>(Message): class <%= msg.name %>(Message):
__id__ = <%= msg.id %> __id__ = <%= msg.id %>
def __init__(self, <%= msg.attributes.map { |attr| "#{attr.name.underscore}" }.join ", " %>): def __init__(self, <%= msg.attributes.map { |attr| "#{attr.name.snake_case}" }.join ", " %>):
<%= msg.attributes.map { |attr| "self.#{attr.name.underscore} = #{attr.name.underscore}" }.join "\n " <%= msg.attributes.map { |attr| "self.#{attr.name.snake_case} = #{attr.name.snake_case}" }.join "\n "
%> %>
<% end %> <% end %>

View file

@ -22,7 +22,7 @@ class MessageCodec(Codec):
if message_id == <%= msg.id %>: if message_id == <%= msg.id %>:
return <%= msg.name %>( return <%= msg.name %>(
<%= msg.attributes.map { |attr| <%= msg.attributes.map { |attr|
"#{attr.name.underscore}=self.read_#{attr.type.to_s}(reader)" } "#{attr.name.snake_case}=self.read_#{attr.type.to_s}(reader)" }
.join ",\n " .join ",\n "
%> %>
) )

View file

@ -16,14 +16,14 @@ export default class RawMessageReader extends PrimitiveReader {
if (tp === null) { return resetPointer() } if (tp === null) { return resetPointer() }
switch (tp) { switch (tp) {
<% $messages.select { |msg| msg.js || msg.replayer }.each do |msg| %> <% $messages.select { |msg| msg.tracker || msg.replayer }.each do |msg| %>
case <%= msg.id %>: { case <%= msg.id %>: {
<%= msg.attributes.map { |attr| <%= msg.attributes.map { |attr|
" const #{attr.name.first_lower} = this.read#{attr.type.to_s.camel_case}(); if (#{attr.name.first_lower} === null) { return resetPointer() }" }.join "\n" %> " const #{attr.name.camel_case} = this.read#{attr.type.to_s.pascal_case}(); if (#{attr.name.camel_case} === null) { return resetPointer() }" }.join "\n" %>
return { return {
tp: "<%= msg.name.underscore %>", tp: "<%= msg.name.snake_case %>",
<%= msg.attributes.map { |attr| <%= msg.attributes.map { |attr|
" #{attr.name.first_lower}," }.join "\n" %> " #{attr.name.camel_case}," }.join "\n" %>
}; };
} }
<% end %> <% end %>

View file

@ -3,11 +3,11 @@
import type { Timed } from './timed' import type { Timed } from './timed'
import type { RawMessage } from './raw' import type { RawMessage } from './raw'
import type { import type {
<%= $messages.select { |msg| msg.js || msg.replayer }.map { |msg| " Raw#{msg.name.underscore.camel_case}," }.join "\n" %> <%= $messages.select { |msg| msg.tracker || msg.replayer }.map { |msg| " Raw#{msg.name.snake_case.pascal_case}," }.join "\n" %>
} from './raw' } from './raw'
export type Message = RawMessage & Timed export type Message = RawMessage & Timed
<% $messages.select { |msg| msg.js || msg.replayer }.each do |msg| %> <% $messages.select { |msg| msg.tracker || msg.replayer }.each do |msg| %>
export type <%= msg.name.underscore.camel_case %> = Raw<%= msg.name.underscore.camel_case %> & Timed export type <%= msg.name.snake_case.pascal_case %> = Raw<%= msg.name.snake_case.pascal_case %> & Timed
<% end %> <% end %>

View file

@ -1,14 +1,14 @@
// Auto-generated, do not edit // Auto-generated, do not edit
export const TP_MAP = { export const TP_MAP = {
<%= $messages.select { |msg| msg.js || msg.replayer }.map { |msg| " #{msg.id}: \"#{msg.name.underscore}\"," }.join "\n" %> <%= $messages.select { |msg| msg.tracker || msg.replayer }.map { |msg| " #{msg.id}: \"#{msg.name.snake_case}\"," }.join "\n" %>
} }
<% $messages.select { |msg| msg.js || msg.replayer }.each do |msg| %> <% $messages.select { |msg| msg.tracker || msg.replayer }.each do |msg| %>
export interface Raw<%= msg.name.underscore.camel_case %> { export interface Raw<%= msg.name.snake_case.pascal_case %> {
tp: "<%= msg.name.underscore %>", tp: "<%= msg.name.snake_case %>",
<%= msg.attributes.map { |attr| " #{attr.name.first_lower}: #{attr.type_js}," }.join "\n" %> <%= msg.attributes.map { |attr| " #{attr.name.camel_case}: #{attr.type_js}," }.join "\n" %>
} }
<% end %> <% end %>
export type RawMessage = <%= $messages.select { |msg| msg.js || msg.replayer }.map { |msg| "Raw#{msg.name.underscore.camel_case}" }.join " | " %>; export type RawMessage = <%= $messages.select { |msg| msg.tracker || msg.replayer }.map { |msg| "Raw#{msg.name.snake_case.pascal_case}" }.join " | " %>;

View file

@ -2,15 +2,15 @@
import UIKit import UIKit
enum ASMessageType: UInt64 { enum ASMessageType: UInt64 {
<%= $messages.map { |msg| " case #{msg.name.first_lower} = #{msg.id}" }.join "\n" %> <%= $messages.map { |msg| " case #{msg.name.camel_case} = #{msg.id}" }.join "\n" %>
} }
<% $messages.each do |msg| %> <% $messages.each do |msg| %>
class AS<%= msg.name.to_s.camel_case %>: ASMessage { class AS<%= msg.name.to_s.pascal_case %>: ASMessage {
<%= msg.attributes[2..-1].map { |attr| " let #{attr.property}: #{attr.type_swift}" }.join "\n" %> <%= msg.attributes[2..-1].map { |attr| " let #{attr.property}: #{attr.type_swift}" }.join "\n" %>
init(<%= msg.attributes[2..-1].map { |attr| "#{attr.property}: #{attr.type_swift}" }.join ", " %>) { init(<%= msg.attributes[2..-1].map { |attr| "#{attr.property}: #{attr.type_swift}" }.join ", " %>) {
<%= msg.attributes[2..-1].map { |attr| " self.#{attr.property} = #{attr.property}" }.join "\n" %> <%= msg.attributes[2..-1].map { |attr| " self.#{attr.property} = #{attr.property}" }.join "\n" %>
super.init(messageType: .<%= "#{msg.name.first_lower}" %>) super.init(messageType: .<%= "#{msg.name.camel_case}" %>)
} }
override init?(genericMessage: GenericMessage) { override init?(genericMessage: GenericMessage) {
@ -30,7 +30,7 @@ class AS<%= msg.name.to_s.camel_case %>: ASMessage {
} }
override var description: String { override var description: String {
return "-->> <%= msg.name.to_s.camel_case %>(<%= "#{msg.id}"%>): timestamp:\(timestamp) <%= msg.attributes[2..-1].map { |attr| "#{attr.property}:\\(#{attr.property})" }.join " "%>"; return "-->> <%= msg.name.to_s.pascal_case %>(<%= "#{msg.id}"%>): timestamp:\(timestamp) <%= msg.attributes[2..-1].map { |attr| "#{attr.property}:\\(#{attr.property})" }.join " "%>";
} }
} }
<% end %> <% end %>

View file

@ -0,0 +1,15 @@
// Auto-generated, do not edit
export enum Type {
<%= $messages.select { |msg| msg.tracker }.map { |msg| "#{ msg.name } = #{ msg.id }," }.join "\n " %>
}
<% $messages.select { |msg| msg.tracker }.each do |msg| %>
export type <%= msg.name %> = [
type: Type.<%= msg.name %>,
<%= msg.attributes.map { |attr| "#{attr.name.camel_case}: #{attr.type_js}," }.join "\n " %>
]
<% end %>
type Message = <%= $messages.select { |msg| msg.tracker }.map { |msg| "#{msg.name}" }.join " | " %>
export default Message

View file

@ -1,31 +0,0 @@
// Auto-generated, do not edit
import type { Writer, Message }from "./types.js";
export default Message
function bindNew<C extends { new(...args: A): T }, A extends any[], T>(
Class: C & { new(...args: A): T }
): C & ((...args: A) => T) {
function _Class(...args: A) {
return new Class(...args);
}
_Class.prototype = Class.prototype;
return <C & ((...args: A) => T)>_Class;
}
export const classes: Map<number, Function> = new Map();
<% $messages.select { |msg| msg.js }.each do |msg| %>
class _<%= msg.name %> implements Message {
readonly _id: number = <%= msg.id %>;
constructor(
<%= msg.attributes.map { |attr| "public #{attr.name.first_lower}: #{attr.type_js}" }.join ",\n " %>
) {}
encode(writer: Writer): boolean {
return writer.uint(<%= msg.id %>)<%= " &&" if msg.attributes.length() > 0 %>
<%= msg.attributes.map { |attr| "writer.#{attr.type}(this.#{attr.name.first_lower})" }.join " &&\n " %>;
}
}
export const <%= msg.name %> = bindNew(_<%= msg.name %>);
classes.set(<%= msg.id %>, <%= msg.name %>);
<% end %>

View file

@ -0,0 +1,15 @@
// Auto-generated, do not edit
import * as Messages from '../../common/messages.gen.js'
export { default } from '../../common/messages.gen.js'
<% $messages.select { |msg| msg.tracker }.each do |msg| %>
export function <%= msg.name %>(
<%= msg.attributes.map { |attr| "#{attr.name.camel_case}: #{attr.type_js}," }.join "\n " %>
): Messages.<%= msg.name %> {
return [
Messages.Type.<%= msg.name %>,
<%= msg.attributes.map { |attr| "#{attr.name.camel_case}," }.join "\n " %>
]
}
<% end %>

View file

@ -0,0 +1,20 @@
// Auto-generated, do not edit
import * as Messages from '../common/messages.gen.js'
import Message from '../common/messages.gen.js'
import PrimitiveEncoder from './PrimitiveEncoder.js'
export default class MessageEncoder extends PrimitiveEncoder {
encode(msg: Message): boolean {
switch(msg[0]) {
<% $messages.select { |msg| msg.tracker }.each do |msg| %>
case Messages.Type.<%= msg.name %>:
return <% if msg.attributes.size == 0 %> true <% else %> <%= msg.attributes.map.with_index { |attr, index| "this.#{attr.type}(msg[#{index+1}])" }.join " && " %> <% end %>
break
<% end %>
}
}
}

View file

@ -5,4 +5,4 @@ cjs
build build
.cache .cache
.eslintrc.cjs .eslintrc.cjs
src/common/messages.ts *.gen.ts

View file

@ -0,0 +1 @@
*.gen.ts

View file

@ -1,5 +1,6 @@
{ {
"printWidth": 100, "printWidth": 100,
"singleQuote": true, "singleQuote": true,
"trailingComma": "all" "trailingComma": "all",
"semi": false
} }

View file

@ -0,0 +1,22 @@
import Message from './messages.gen.js'
export interface Options {
connAttemptCount?: number
connAttemptGap?: number
}
type Start = {
type: 'start'
ingestPoint: string
pageNo: number
timestamp: number
url: string
} & Options
type Auth = {
type: 'auth'
token: string
beaconSizeLimit?: number
}
export type WorkerMessageData = null | 'stop' | Start | Auth | Array<Message>

View file

@ -0,0 +1,405 @@
// Auto-generated, do not edit
export enum Type {
BatchMetadata = 81,
PartitionedMessage = 82,
Timestamp = 0,
SetPageLocation = 4,
SetViewportSize = 5,
SetViewportScroll = 6,
CreateDocument = 7,
CreateElementNode = 8,
CreateTextNode = 9,
MoveNode = 10,
RemoveNode = 11,
SetNodeAttribute = 12,
RemoveNodeAttribute = 13,
SetNodeData = 14,
SetNodeScroll = 16,
SetInputTarget = 17,
SetInputValue = 18,
SetInputChecked = 19,
MouseMove = 20,
ConsoleLog = 22,
PageLoadTiming = 23,
PageRenderTiming = 24,
JSException = 25,
RawCustomEvent = 27,
UserID = 28,
UserAnonymousID = 29,
Metadata = 30,
CSSInsertRule = 37,
CSSDeleteRule = 38,
Fetch = 39,
Profiler = 40,
OTable = 41,
StateAction = 42,
Redux = 44,
Vuex = 45,
MobX = 46,
NgRx = 47,
GraphQL = 48,
PerformanceTrack = 49,
ResourceTiming = 53,
ConnectionInformation = 54,
SetPageVisibility = 55,
LongTask = 59,
SetNodeAttributeURLBased = 60,
SetCSSDataURLBased = 61,
TechnicalInfo = 63,
CustomIssue = 64,
CSSInsertRuleURLBased = 67,
MouseClick = 69,
CreateIFrameDocument = 70,
}
export type BatchMetadata = [
type: Type.BatchMetadata,
version: number,
pageNo: number,
firstIndex: number,
timestamp: number,
location: string,
]
export type PartitionedMessage = [
type: Type.PartitionedMessage,
partNo: number,
partTotal: number,
]
export type Timestamp = [
type: Type.Timestamp,
timestamp: number,
]
export type SetPageLocation = [
type: Type.SetPageLocation,
url: string,
referrer: string,
navigationStart: number,
]
export type SetViewportSize = [
type: Type.SetViewportSize,
width: number,
height: number,
]
export type SetViewportScroll = [
type: Type.SetViewportScroll,
x: number,
y: number,
]
export type CreateDocument = [
type: Type.CreateDocument,
]
export type CreateElementNode = [
type: Type.CreateElementNode,
id: number,
parentID: number,
index: number,
tag: string,
svg: boolean,
]
export type CreateTextNode = [
type: Type.CreateTextNode,
id: number,
parentID: number,
index: number,
]
export type MoveNode = [
type: Type.MoveNode,
id: number,
parentID: number,
index: number,
]
export type RemoveNode = [
type: Type.RemoveNode,
id: number,
]
export type SetNodeAttribute = [
type: Type.SetNodeAttribute,
id: number,
name: string,
value: string,
]
export type RemoveNodeAttribute = [
type: Type.RemoveNodeAttribute,
id: number,
name: string,
]
export type SetNodeData = [
type: Type.SetNodeData,
id: number,
data: string,
]
export type SetNodeScroll = [
type: Type.SetNodeScroll,
id: number,
x: number,
y: number,
]
export type SetInputTarget = [
type: Type.SetInputTarget,
id: number,
label: string,
]
export type SetInputValue = [
type: Type.SetInputValue,
id: number,
value: string,
mask: number,
]
export type SetInputChecked = [
type: Type.SetInputChecked,
id: number,
checked: boolean,
]
export type MouseMove = [
type: Type.MouseMove,
x: number,
y: number,
]
export type ConsoleLog = [
type: Type.ConsoleLog,
level: string,
value: string,
]
export type PageLoadTiming = [
type: Type.PageLoadTiming,
requestStart: number,
responseStart: number,
responseEnd: number,
domContentLoadedEventStart: number,
domContentLoadedEventEnd: number,
loadEventStart: number,
loadEventEnd: number,
firstPaint: number,
firstContentfulPaint: number,
]
export type PageRenderTiming = [
type: Type.PageRenderTiming,
speedIndex: number,
visuallyComplete: number,
timeToInteractive: number,
]
export type JSException = [
type: Type.JSException,
name: string,
message: string,
payload: string,
]
export type RawCustomEvent = [
type: Type.RawCustomEvent,
name: string,
payload: string,
]
export type UserID = [
type: Type.UserID,
id: string,
]
export type UserAnonymousID = [
type: Type.UserAnonymousID,
id: string,
]
export type Metadata = [
type: Type.Metadata,
key: string,
value: string,
]
export type CSSInsertRule = [
type: Type.CSSInsertRule,
id: number,
rule: string,
index: number,
]
export type CSSDeleteRule = [
type: Type.CSSDeleteRule,
id: number,
index: number,
]
export type Fetch = [
type: Type.Fetch,
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
]
export type Profiler = [
type: Type.Profiler,
name: string,
duration: number,
args: string,
result: string,
]
export type OTable = [
type: Type.OTable,
key: string,
value: string,
]
export type StateAction = [
type: Type.StateAction,
type: string,
]
export type Redux = [
type: Type.Redux,
action: string,
state: string,
duration: number,
]
export type Vuex = [
type: Type.Vuex,
mutation: string,
state: string,
]
export type MobX = [
type: Type.MobX,
type: string,
payload: string,
]
export type NgRx = [
type: Type.NgRx,
action: string,
state: string,
duration: number,
]
export type GraphQL = [
type: Type.GraphQL,
operationKind: string,
operationName: string,
variables: string,
response: string,
]
export type PerformanceTrack = [
type: Type.PerformanceTrack,
frames: number,
ticks: number,
totalJSHeapSize: number,
usedJSHeapSize: number,
]
export type ResourceTiming = [
type: Type.ResourceTiming,
timestamp: number,
duration: number,
ttfb: number,
headerSize: number,
encodedBodySize: number,
decodedBodySize: number,
url: string,
initiator: string,
]
export type ConnectionInformation = [
type: Type.ConnectionInformation,
downlink: number,
type: string,
]
export type SetPageVisibility = [
type: Type.SetPageVisibility,
hidden: boolean,
]
export type LongTask = [
type: Type.LongTask,
timestamp: number,
duration: number,
context: number,
containerType: number,
containerSrc: string,
containerId: string,
containerName: string,
]
export type SetNodeAttributeURLBased = [
type: Type.SetNodeAttributeURLBased,
id: number,
name: string,
value: string,
baseURL: string,
]
export type SetCSSDataURLBased = [
type: Type.SetCSSDataURLBased,
id: number,
data: string,
baseURL: string,
]
export type TechnicalInfo = [
type: Type.TechnicalInfo,
type: string,
value: string,
]
export type CustomIssue = [
type: Type.CustomIssue,
name: string,
payload: string,
]
export type CSSInsertRuleURLBased = [
type: Type.CSSInsertRuleURLBased,
id: number,
rule: string,
index: number,
baseURL: string,
]
export type MouseClick = [
type: Type.MouseClick,
id: number,
hesitationTime: number,
label: string,
selector: string,
]
export type CreateIFrameDocument = [
type: Type.CreateIFrameDocument,
frameID: number,
id: number,
]
type Message = BatchMetadata | PartitionedMessage | Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | PageLoadTiming | PageRenderTiming | JSException | RawCustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | ResourceTiming | ConnectionInformation | SetPageVisibility | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument
export default Message

View file

@ -1,903 +0,0 @@
// Auto-generated, do not edit
import type { Writer, Message }from "./types.js";
export default Message
function bindNew<C extends { new(...args: A): T }, A extends any[], T>(
Class: C & { new(...args: A): T }
): C & ((...args: A) => T) {
function _Class(...args: A) {
return new Class(...args);
}
_Class.prototype = Class.prototype;
return <C & ((...args: A) => T)>_Class;
}
export const classes: Map<number, Function> = new Map();
class _BatchMeta implements Message {
readonly _id: number = 80;
constructor(
public pageNo: number,
public firstIndex: number,
public timestamp: number
) {}
encode(writer: Writer): boolean {
return writer.uint(80) &&
writer.uint(this.pageNo) &&
writer.uint(this.firstIndex) &&
writer.int(this.timestamp);
}
}
export const BatchMeta = bindNew(_BatchMeta);
classes.set(80, BatchMeta);
class _Timestamp implements Message {
readonly _id: number = 0;
constructor(
public timestamp: number
) {}
encode(writer: Writer): boolean {
return writer.uint(0) &&
writer.uint(this.timestamp);
}
}
export const Timestamp = bindNew(_Timestamp);
classes.set(0, Timestamp);
class _SetPageLocation implements Message {
readonly _id: number = 4;
constructor(
public url: string,
public referrer: string,
public navigationStart: number
) {}
encode(writer: Writer): boolean {
return writer.uint(4) &&
writer.string(this.url) &&
writer.string(this.referrer) &&
writer.uint(this.navigationStart);
}
}
export const SetPageLocation = bindNew(_SetPageLocation);
classes.set(4, SetPageLocation);
class _SetViewportSize implements Message {
readonly _id: number = 5;
constructor(
public width: number,
public height: number
) {}
encode(writer: Writer): boolean {
return writer.uint(5) &&
writer.uint(this.width) &&
writer.uint(this.height);
}
}
export const SetViewportSize = bindNew(_SetViewportSize);
classes.set(5, SetViewportSize);
class _SetViewportScroll implements Message {
readonly _id: number = 6;
constructor(
public x: number,
public y: number
) {}
encode(writer: Writer): boolean {
return writer.uint(6) &&
writer.int(this.x) &&
writer.int(this.y);
}
}
export const SetViewportScroll = bindNew(_SetViewportScroll);
classes.set(6, SetViewportScroll);
class _CreateDocument implements Message {
readonly _id: number = 7;
constructor(
) {}
encode(writer: Writer): boolean {
return writer.uint(7)
;
}
}
export const CreateDocument = bindNew(_CreateDocument);
classes.set(7, CreateDocument);
class _CreateElementNode implements Message {
readonly _id: number = 8;
constructor(
public id: number,
public parentID: number,
public index: number,
public tag: string,
public svg: boolean
) {}
encode(writer: Writer): boolean {
return writer.uint(8) &&
writer.uint(this.id) &&
writer.uint(this.parentID) &&
writer.uint(this.index) &&
writer.string(this.tag) &&
writer.boolean(this.svg);
}
}
export const CreateElementNode = bindNew(_CreateElementNode);
classes.set(8, CreateElementNode);
class _CreateTextNode implements Message {
readonly _id: number = 9;
constructor(
public id: number,
public parentID: number,
public index: number
) {}
encode(writer: Writer): boolean {
return writer.uint(9) &&
writer.uint(this.id) &&
writer.uint(this.parentID) &&
writer.uint(this.index);
}
}
export const CreateTextNode = bindNew(_CreateTextNode);
classes.set(9, CreateTextNode);
class _MoveNode implements Message {
readonly _id: number = 10;
constructor(
public id: number,
public parentID: number,
public index: number
) {}
encode(writer: Writer): boolean {
return writer.uint(10) &&
writer.uint(this.id) &&
writer.uint(this.parentID) &&
writer.uint(this.index);
}
}
export const MoveNode = bindNew(_MoveNode);
classes.set(10, MoveNode);
class _RemoveNode implements Message {
readonly _id: number = 11;
constructor(
public id: number
) {}
encode(writer: Writer): boolean {
return writer.uint(11) &&
writer.uint(this.id);
}
}
export const RemoveNode = bindNew(_RemoveNode);
classes.set(11, RemoveNode);
class _SetNodeAttribute implements Message {
readonly _id: number = 12;
constructor(
public id: number,
public name: string,
public value: string
) {}
encode(writer: Writer): boolean {
return writer.uint(12) &&
writer.uint(this.id) &&
writer.string(this.name) &&
writer.string(this.value);
}
}
export const SetNodeAttribute = bindNew(_SetNodeAttribute);
classes.set(12, SetNodeAttribute);
class _RemoveNodeAttribute implements Message {
readonly _id: number = 13;
constructor(
public id: number,
public name: string
) {}
encode(writer: Writer): boolean {
return writer.uint(13) &&
writer.uint(this.id) &&
writer.string(this.name);
}
}
export const RemoveNodeAttribute = bindNew(_RemoveNodeAttribute);
classes.set(13, RemoveNodeAttribute);
class _SetNodeData implements Message {
readonly _id: number = 14;
constructor(
public id: number,
public data: string
) {}
encode(writer: Writer): boolean {
return writer.uint(14) &&
writer.uint(this.id) &&
writer.string(this.data);
}
}
export const SetNodeData = bindNew(_SetNodeData);
classes.set(14, SetNodeData);
class _SetNodeScroll implements Message {
readonly _id: number = 16;
constructor(
public id: number,
public x: number,
public y: number
) {}
encode(writer: Writer): boolean {
return writer.uint(16) &&
writer.uint(this.id) &&
writer.int(this.x) &&
writer.int(this.y);
}
}
export const SetNodeScroll = bindNew(_SetNodeScroll);
classes.set(16, SetNodeScroll);
class _SetInputTarget implements Message {
readonly _id: number = 17;
constructor(
public id: number,
public label: string
) {}
encode(writer: Writer): boolean {
return writer.uint(17) &&
writer.uint(this.id) &&
writer.string(this.label);
}
}
export const SetInputTarget = bindNew(_SetInputTarget);
classes.set(17, SetInputTarget);
class _SetInputValue implements Message {
readonly _id: number = 18;
constructor(
public id: number,
public value: string,
public mask: number
) {}
encode(writer: Writer): boolean {
return writer.uint(18) &&
writer.uint(this.id) &&
writer.string(this.value) &&
writer.int(this.mask);
}
}
export const SetInputValue = bindNew(_SetInputValue);
classes.set(18, SetInputValue);
class _SetInputChecked implements Message {
readonly _id: number = 19;
constructor(
public id: number,
public checked: boolean
) {}
encode(writer: Writer): boolean {
return writer.uint(19) &&
writer.uint(this.id) &&
writer.boolean(this.checked);
}
}
export const SetInputChecked = bindNew(_SetInputChecked);
classes.set(19, SetInputChecked);
class _MouseMove implements Message {
readonly _id: number = 20;
constructor(
public x: number,
public y: number
) {}
encode(writer: Writer): boolean {
return writer.uint(20) &&
writer.uint(this.x) &&
writer.uint(this.y);
}
}
export const MouseMove = bindNew(_MouseMove);
classes.set(20, MouseMove);
class _ConsoleLog implements Message {
readonly _id: number = 22;
constructor(
public level: string,
public value: string
) {}
encode(writer: Writer): boolean {
return writer.uint(22) &&
writer.string(this.level) &&
writer.string(this.value);
}
}
export const ConsoleLog = bindNew(_ConsoleLog);
classes.set(22, ConsoleLog);
class _PageLoadTiming implements Message {
readonly _id: number = 23;
constructor(
public requestStart: number,
public responseStart: number,
public responseEnd: number,
public domContentLoadedEventStart: number,
public domContentLoadedEventEnd: number,
public loadEventStart: number,
public loadEventEnd: number,
public firstPaint: number,
public firstContentfulPaint: number
) {}
encode(writer: Writer): boolean {
return writer.uint(23) &&
writer.uint(this.requestStart) &&
writer.uint(this.responseStart) &&
writer.uint(this.responseEnd) &&
writer.uint(this.domContentLoadedEventStart) &&
writer.uint(this.domContentLoadedEventEnd) &&
writer.uint(this.loadEventStart) &&
writer.uint(this.loadEventEnd) &&
writer.uint(this.firstPaint) &&
writer.uint(this.firstContentfulPaint);
}
}
export const PageLoadTiming = bindNew(_PageLoadTiming);
classes.set(23, PageLoadTiming);
class _PageRenderTiming implements Message {
readonly _id: number = 24;
constructor(
public speedIndex: number,
public visuallyComplete: number,
public timeToInteractive: number
) {}
encode(writer: Writer): boolean {
return writer.uint(24) &&
writer.uint(this.speedIndex) &&
writer.uint(this.visuallyComplete) &&
writer.uint(this.timeToInteractive);
}
}
export const PageRenderTiming = bindNew(_PageRenderTiming);
classes.set(24, PageRenderTiming);
class _JSException implements Message {
readonly _id: number = 25;
constructor(
public name: string,
public message: string,
public payload: string
) {}
encode(writer: Writer): boolean {
return writer.uint(25) &&
writer.string(this.name) &&
writer.string(this.message) &&
writer.string(this.payload);
}
}
export const JSException = bindNew(_JSException);
classes.set(25, JSException);
class _RawCustomEvent implements Message {
readonly _id: number = 27;
constructor(
public name: string,
public payload: string
) {}
encode(writer: Writer): boolean {
return writer.uint(27) &&
writer.string(this.name) &&
writer.string(this.payload);
}
}
export const RawCustomEvent = bindNew(_RawCustomEvent);
classes.set(27, RawCustomEvent);
class _UserID implements Message {
readonly _id: number = 28;
constructor(
public id: string
) {}
encode(writer: Writer): boolean {
return writer.uint(28) &&
writer.string(this.id);
}
}
export const UserID = bindNew(_UserID);
classes.set(28, UserID);
class _UserAnonymousID implements Message {
readonly _id: number = 29;
constructor(
public id: string
) {}
encode(writer: Writer): boolean {
return writer.uint(29) &&
writer.string(this.id);
}
}
export const UserAnonymousID = bindNew(_UserAnonymousID);
classes.set(29, UserAnonymousID);
class _Metadata implements Message {
readonly _id: number = 30;
constructor(
public key: string,
public value: string
) {}
encode(writer: Writer): boolean {
return writer.uint(30) &&
writer.string(this.key) &&
writer.string(this.value);
}
}
export const Metadata = bindNew(_Metadata);
classes.set(30, Metadata);
class _CSSInsertRule implements Message {
readonly _id: number = 37;
constructor(
public id: number,
public rule: string,
public index: number
) {}
encode(writer: Writer): boolean {
return writer.uint(37) &&
writer.uint(this.id) &&
writer.string(this.rule) &&
writer.uint(this.index);
}
}
export const CSSInsertRule = bindNew(_CSSInsertRule);
classes.set(37, CSSInsertRule);
class _CSSDeleteRule implements Message {
readonly _id: number = 38;
constructor(
public id: number,
public index: number
) {}
encode(writer: Writer): boolean {
return writer.uint(38) &&
writer.uint(this.id) &&
writer.uint(this.index);
}
}
export const CSSDeleteRule = bindNew(_CSSDeleteRule);
classes.set(38, CSSDeleteRule);
class _Fetch implements Message {
readonly _id: number = 39;
constructor(
public method: string,
public url: string,
public request: string,
public response: string,
public status: number,
public timestamp: number,
public duration: number
) {}
encode(writer: Writer): boolean {
return writer.uint(39) &&
writer.string(this.method) &&
writer.string(this.url) &&
writer.string(this.request) &&
writer.string(this.response) &&
writer.uint(this.status) &&
writer.uint(this.timestamp) &&
writer.uint(this.duration);
}
}
export const Fetch = bindNew(_Fetch);
classes.set(39, Fetch);
class _Profiler implements Message {
readonly _id: number = 40;
constructor(
public name: string,
public duration: number,
public args: string,
public result: string
) {}
encode(writer: Writer): boolean {
return writer.uint(40) &&
writer.string(this.name) &&
writer.uint(this.duration) &&
writer.string(this.args) &&
writer.string(this.result);
}
}
export const Profiler = bindNew(_Profiler);
classes.set(40, Profiler);
class _OTable implements Message {
readonly _id: number = 41;
constructor(
public key: string,
public value: string
) {}
encode(writer: Writer): boolean {
return writer.uint(41) &&
writer.string(this.key) &&
writer.string(this.value);
}
}
export const OTable = bindNew(_OTable);
classes.set(41, OTable);
class _StateAction implements Message {
readonly _id: number = 42;
constructor(
public type: string
) {}
encode(writer: Writer): boolean {
return writer.uint(42) &&
writer.string(this.type);
}
}
export const StateAction = bindNew(_StateAction);
classes.set(42, StateAction);
class _Redux implements Message {
readonly _id: number = 44;
constructor(
public action: string,
public state: string,
public duration: number
) {}
encode(writer: Writer): boolean {
return writer.uint(44) &&
writer.string(this.action) &&
writer.string(this.state) &&
writer.uint(this.duration);
}
}
export const Redux = bindNew(_Redux);
classes.set(44, Redux);
class _Vuex implements Message {
readonly _id: number = 45;
constructor(
public mutation: string,
public state: string
) {}
encode(writer: Writer): boolean {
return writer.uint(45) &&
writer.string(this.mutation) &&
writer.string(this.state);
}
}
export const Vuex = bindNew(_Vuex);
classes.set(45, Vuex);
class _MobX implements Message {
readonly _id: number = 46;
constructor(
public type: string,
public payload: string
) {}
encode(writer: Writer): boolean {
return writer.uint(46) &&
writer.string(this.type) &&
writer.string(this.payload);
}
}
export const MobX = bindNew(_MobX);
classes.set(46, MobX);
class _NgRx implements Message {
readonly _id: number = 47;
constructor(
public action: string,
public state: string,
public duration: number
) {}
encode(writer: Writer): boolean {
return writer.uint(47) &&
writer.string(this.action) &&
writer.string(this.state) &&
writer.uint(this.duration);
}
}
export const NgRx = bindNew(_NgRx);
classes.set(47, NgRx);
class _GraphQL implements Message {
readonly _id: number = 48;
constructor(
public operationKind: string,
public operationName: string,
public variables: string,
public response: string
) {}
encode(writer: Writer): boolean {
return writer.uint(48) &&
writer.string(this.operationKind) &&
writer.string(this.operationName) &&
writer.string(this.variables) &&
writer.string(this.response);
}
}
export const GraphQL = bindNew(_GraphQL);
classes.set(48, GraphQL);
class _PerformanceTrack implements Message {
readonly _id: number = 49;
constructor(
public frames: number,
public ticks: number,
public totalJSHeapSize: number,
public usedJSHeapSize: number
) {}
encode(writer: Writer): boolean {
return writer.uint(49) &&
writer.int(this.frames) &&
writer.int(this.ticks) &&
writer.uint(this.totalJSHeapSize) &&
writer.uint(this.usedJSHeapSize);
}
}
export const PerformanceTrack = bindNew(_PerformanceTrack);
classes.set(49, PerformanceTrack);
class _ResourceTiming implements Message {
readonly _id: number = 53;
constructor(
public timestamp: number,
public duration: number,
public ttfb: number,
public headerSize: number,
public encodedBodySize: number,
public decodedBodySize: number,
public url: string,
public initiator: string
) {}
encode(writer: Writer): boolean {
return writer.uint(53) &&
writer.uint(this.timestamp) &&
writer.uint(this.duration) &&
writer.uint(this.ttfb) &&
writer.uint(this.headerSize) &&
writer.uint(this.encodedBodySize) &&
writer.uint(this.decodedBodySize) &&
writer.string(this.url) &&
writer.string(this.initiator);
}
}
export const ResourceTiming = bindNew(_ResourceTiming);
classes.set(53, ResourceTiming);
class _ConnectionInformation implements Message {
readonly _id: number = 54;
constructor(
public downlink: number,
public type: string
) {}
encode(writer: Writer): boolean {
return writer.uint(54) &&
writer.uint(this.downlink) &&
writer.string(this.type);
}
}
export const ConnectionInformation = bindNew(_ConnectionInformation);
classes.set(54, ConnectionInformation);
class _SetPageVisibility implements Message {
readonly _id: number = 55;
constructor(
public hidden: boolean
) {}
encode(writer: Writer): boolean {
return writer.uint(55) &&
writer.boolean(this.hidden);
}
}
export const SetPageVisibility = bindNew(_SetPageVisibility);
classes.set(55, SetPageVisibility);
class _LongTask implements Message {
readonly _id: number = 59;
constructor(
public timestamp: number,
public duration: number,
public context: number,
public containerType: number,
public containerSrc: string,
public containerId: string,
public containerName: string
) {}
encode(writer: Writer): boolean {
return writer.uint(59) &&
writer.uint(this.timestamp) &&
writer.uint(this.duration) &&
writer.uint(this.context) &&
writer.uint(this.containerType) &&
writer.string(this.containerSrc) &&
writer.string(this.containerId) &&
writer.string(this.containerName);
}
}
export const LongTask = bindNew(_LongTask);
classes.set(59, LongTask);
class _SetNodeAttributeURLBased implements Message {
readonly _id: number = 60;
constructor(
public id: number,
public name: string,
public value: string,
public baseURL: string
) {}
encode(writer: Writer): boolean {
return writer.uint(60) &&
writer.uint(this.id) &&
writer.string(this.name) &&
writer.string(this.value) &&
writer.string(this.baseURL);
}
}
export const SetNodeAttributeURLBased = bindNew(_SetNodeAttributeURLBased);
classes.set(60, SetNodeAttributeURLBased);
class _SetCSSDataURLBased implements Message {
readonly _id: number = 61;
constructor(
public id: number,
public data: string,
public baseURL: string
) {}
encode(writer: Writer): boolean {
return writer.uint(61) &&
writer.uint(this.id) &&
writer.string(this.data) &&
writer.string(this.baseURL);
}
}
export const SetCSSDataURLBased = bindNew(_SetCSSDataURLBased);
classes.set(61, SetCSSDataURLBased);
class _TechnicalInfo implements Message {
readonly _id: number = 63;
constructor(
public type: string,
public value: string
) {}
encode(writer: Writer): boolean {
return writer.uint(63) &&
writer.string(this.type) &&
writer.string(this.value);
}
}
export const TechnicalInfo = bindNew(_TechnicalInfo);
classes.set(63, TechnicalInfo);
class _CustomIssue implements Message {
readonly _id: number = 64;
constructor(
public name: string,
public payload: string
) {}
encode(writer: Writer): boolean {
return writer.uint(64) &&
writer.string(this.name) &&
writer.string(this.payload);
}
}
export const CustomIssue = bindNew(_CustomIssue);
classes.set(64, CustomIssue);
class _PageClose implements Message {
readonly _id: number = 65;
constructor(
) {}
encode(writer: Writer): boolean {
return writer.uint(65)
;
}
}
export const PageClose = bindNew(_PageClose);
classes.set(65, PageClose);
class _CSSInsertRuleURLBased implements Message {
readonly _id: number = 67;
constructor(
public id: number,
public rule: string,
public index: number,
public baseURL: string
) {}
encode(writer: Writer): boolean {
return writer.uint(67) &&
writer.uint(this.id) &&
writer.string(this.rule) &&
writer.uint(this.index) &&
writer.string(this.baseURL);
}
}
export const CSSInsertRuleURLBased = bindNew(_CSSInsertRuleURLBased);
classes.set(67, CSSInsertRuleURLBased);
class _MouseClick implements Message {
readonly _id: number = 69;
constructor(
public id: number,
public hesitationTime: number,
public label: string,
public selector: string
) {}
encode(writer: Writer): boolean {
return writer.uint(69) &&
writer.uint(this.id) &&
writer.uint(this.hesitationTime) &&
writer.string(this.label) &&
writer.string(this.selector);
}
}
export const MouseClick = bindNew(_MouseClick);
classes.set(69, MouseClick);
class _CreateIFrameDocument implements Message {
readonly _id: number = 70;
constructor(
public frameID: number,
public id: number
) {}
encode(writer: Writer): boolean {
return writer.uint(70) &&
writer.uint(this.frameID) &&
writer.uint(this.id);
}
}
export const CreateIFrameDocument = bindNew(_CreateIFrameDocument);
classes.set(70, CreateIFrameDocument);

View file

@ -1,10 +0,0 @@
export interface Writer {
uint(n: number): boolean
int(n: number): boolean
string(s: string): boolean
boolean(b: boolean): boolean
}
export interface Message {
encode(w: Writer): boolean;
}

View file

@ -1,19 +0,0 @@
export interface Options {
connAttemptCount?: number;
connAttemptGap?: number;
}
type Start = {
type: 'start';
ingestPoint: string;
pageNo: number;
timestamp: number;
} & Options;
type Auth = {
type: 'auth';
token: string;
beaconSizeLimit?: number;
};
export type WorkerMessageData = null | 'stop' | Start | Auth | Array<{ _id: number }>;

View file

@ -1,34 +1,34 @@
export function isSVGElement(node: Element): node is SVGElement { export function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg'; return node.namespaceURI === 'http://www.w3.org/2000/svg'
} }
export function isElementNode(node: Node): node is Element { export function isElementNode(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE; return node.nodeType === Node.ELEMENT_NODE
} }
export function isTextNode(node: Node): node is Text { export function isTextNode(node: Node): node is Text {
return node.nodeType === Node.TEXT_NODE; return node.nodeType === Node.TEXT_NODE
} }
export function isRootNode(node: Node): boolean { export function isRootNode(node: Node): boolean {
return node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE; return node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE
} }
type TagTypeMap = { type TagTypeMap = {
HTML: HTMLHtmlElement; HTML: HTMLHtmlElement
IMG: HTMLImageElement; IMG: HTMLImageElement
INPUT: HTMLInputElement; INPUT: HTMLInputElement
TEXTAREA: HTMLTextAreaElement; TEXTAREA: HTMLTextAreaElement
SELECT: HTMLSelectElement; SELECT: HTMLSelectElement
LABEL: HTMLLabelElement; LABEL: HTMLLabelElement
IFRAME: HTMLIFrameElement; IFRAME: HTMLIFrameElement
STYLE: HTMLStyleElement; STYLE: HTMLStyleElement
style: SVGStyleElement; style: SVGStyleElement
LINK: HTMLLinkElement; LINK: HTMLLinkElement
}; }
export function hasTag<T extends keyof TagTypeMap>( export function hasTag<T extends keyof TagTypeMap>(
el: Node, el: Node,
tagName: T, tagName: T,
): el is TagTypeMap[typeof tagName] { ): el is TagTypeMap[typeof tagName] {
return el.nodeName === tagName; return el.nodeName === tagName
} }

View file

@ -1,45 +1,45 @@
import type Message from '../../common/messages.js'; import type Message from './messages.gen.js'
import { Timestamp, Metadata, UserID } from '../../common/messages.js'; import { Timestamp, Metadata, UserID } from './messages.gen.js'
import { timestamp, deprecationWarn } from '../utils.js'; import { timestamp, deprecationWarn } from '../utils.js'
import Nodes from './nodes.js'; import Nodes from './nodes.js'
import Observer from './observer/top_observer.js'; import Observer from './observer/top_observer.js'
import Sanitizer from './sanitizer.js'; import Sanitizer from './sanitizer.js'
import Ticker from './ticker.js'; import Ticker from './ticker.js'
import Logger, { LogLevel } from './logger.js'; import Logger, { LogLevel } from './logger.js'
import Session from './session.js'; import Session from './session.js'
import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js'; import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js'
import type { Options as ObserverOptions } from './observer/top_observer.js'; import type { Options as ObserverOptions } from './observer/top_observer.js'
import type { Options as SanitizerOptions } from './sanitizer.js'; import type { Options as SanitizerOptions } from './sanitizer.js'
import type { Options as LoggerOptions } from './logger.js'; import type { Options as LoggerOptions } from './logger.js'
import type { Options as WebworkerOptions, WorkerMessageData } from '../../common/webworker.js'; import type { Options as WebworkerOptions, WorkerMessageData } from '../../common/interaction.js'
// TODO: Unify and clearly describe options logic // TODO: Unify and clearly describe options logic
export interface StartOptions { export interface StartOptions {
userID?: string; userID?: string
metadata?: Record<string, string>; metadata?: Record<string, string>
forceNew?: boolean; forceNew?: boolean
} }
interface OnStartInfo { interface OnStartInfo {
sessionID: string; sessionID: string
sessionToken: string; sessionToken: string
userUUID: string; userUUID: string
} }
const CANCELED = 'canceled' as const; const CANCELED = 'canceled' as const
const START_ERROR = ':(' as const; const START_ERROR = ':(' as const
type SuccessfulStart = OnStartInfo & { success: true }; type SuccessfulStart = OnStartInfo & { success: true }
type UnsuccessfulStart = { type UnsuccessfulStart = {
reason: typeof CANCELED | string; reason: typeof CANCELED | string
success: false; success: false
}; }
const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false }); const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false })
const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true }); const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true })
export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart; export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart
type StartCallback = (i: OnStartInfo) => void; type StartCallback = (i: OnStartInfo) => void
type CommitCallback = (messages: Array<Message>) => void; type CommitCallback = (messages: Array<Message>) => void
enum ActivityState { enum ActivityState {
NotActive, NotActive,
Starting, Starting,
@ -47,51 +47,51 @@ enum ActivityState {
} }
type AppOptions = { type AppOptions = {
revID: string; revID: string
node_id: string; node_id: string
session_token_key: string; session_token_key: string
session_pageno_key: string; session_pageno_key: string
session_reset_key: string; session_reset_key: string
local_uuid_key: string; local_uuid_key: string
ingestPoint: string; ingestPoint: string
resourceBaseHref: string | null; // resourceHref? resourceBaseHref: string | null // resourceHref?
//resourceURLRewriter: (url: string) => string | boolean, //resourceURLRewriter: (url: string) => string | boolean,
verbose: boolean; verbose: boolean
__is_snippet: boolean; __is_snippet: boolean
__debug_report_edp: string | null; __debug_report_edp: string | null
__debug__?: LoggerOptions; __debug__?: LoggerOptions
localStorage: Storage | null; localStorage: Storage
sessionStorage: Storage | null; sessionStorage: Storage
// @deprecated // @deprecated
onStart?: StartCallback; onStart?: StartCallback
} & WebworkerOptions; } & WebworkerOptions
export type Options = AppOptions & ObserverOptions & SanitizerOptions; export type Options = AppOptions & ObserverOptions & SanitizerOptions
// TODO: use backendHost only // TODO: use backendHost only
export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest'; export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest'
export default class App { export default class App {
readonly nodes: Nodes; readonly nodes: Nodes
readonly ticker: Ticker; readonly ticker: Ticker
readonly projectKey: string; readonly projectKey: string
readonly sanitizer: Sanitizer; readonly sanitizer: Sanitizer
readonly debug: Logger; readonly debug: Logger
readonly notify: Logger; readonly notify: Logger
readonly session: Session; readonly session: Session
readonly localStorage: Storage; readonly localStorage: Storage
readonly sessionStorage: Storage; readonly sessionStorage: Storage
private readonly messages: Array<Message> = []; private readonly messages: Array<Message> = []
private readonly observer: Observer; private readonly observer: Observer
private readonly startCallbacks: Array<StartCallback> = []; private readonly startCallbacks: Array<StartCallback> = []
private readonly stopCallbacks: Array<() => any> = []; private readonly stopCallbacks: Array<() => any> = []
private readonly commitCallbacks: Array<CommitCallback> = []; private readonly commitCallbacks: Array<CommitCallback> = []
private readonly options: AppOptions; private readonly options: AppOptions
private readonly revID: string; private readonly revID: string
private activityState: ActivityState = ActivityState.NotActive; private activityState: ActivityState = ActivityState.NotActive
private readonly version = 'TRACKER_VERSION'; // TODO: version compatability check inside each plugin. private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin.
private readonly worker?: Worker; private readonly worker?: Worker
constructor( constructor(
projectKey: string, projectKey: string,
sessionToken: string | null | undefined, sessionToken: string | null | undefined,
@ -101,7 +101,7 @@ export default class App {
// deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)") // deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)")
// } ?? maybe onStart is good // } ?? maybe onStart is good
this.projectKey = projectKey; this.projectKey = projectKey
this.options = Object.assign( this.options = Object.assign(
{ {
revID: '', revID: '',
@ -119,63 +119,61 @@ export default class App {
sessionStorage: null, sessionStorage: null,
}, },
options, options,
); )
this.revID = this.options.revID; this.revID = this.options.revID
this.sanitizer = new Sanitizer(this, options); this.sanitizer = new Sanitizer(this, options)
this.nodes = new Nodes(this.options.node_id); this.nodes = new Nodes(this.options.node_id)
this.observer = new Observer(this, options); this.observer = new Observer(this, options)
this.ticker = new Ticker(this); this.ticker = new Ticker(this)
this.ticker.attach(() => this.commit()); this.ticker.attach(() => this.commit())
this.debug = new Logger(this.options.__debug__); this.debug = new Logger(this.options.__debug__)
this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent); this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent)
this.session = new Session(); this.session = new Session()
this.session.attachUpdateCallback(({ userID, metadata }) => { this.session.attachUpdateCallback(({ userID, metadata }) => {
if (userID != null) { if (userID != null) {
// TODO: nullable userID // TODO: nullable userID
this.send(new UserID(userID)); this.send(UserID(userID))
} }
if (metadata != null) { if (metadata != null) {
Object.entries(metadata).forEach(([key, value]) => this.send(new Metadata(key, value))); Object.entries(metadata).forEach(([key, value]) => this.send(Metadata(key, value)))
} }
}); })
this.localStorage = this.options.localStorage
// window.localStorage and window.sessionStorage should only be accessed if required, see #490, #637 this.sessionStorage = this.options.sessionStorage
this.localStorage = this.options.localStorage ?? window.localStorage;
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage;
if (sessionToken != null) { if (sessionToken != null) {
this.sessionStorage.setItem(this.options.session_token_key, sessionToken); this.sessionStorage.setItem(this.options.session_token_key, sessionToken)
} }
try { try {
this.worker = new Worker( this.worker = new Worker(
URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })), URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })),
); )
this.worker.onerror = (e) => { this.worker.onerror = (e) => {
this._debug('webworker_error', e); this._debug('webworker_error', e)
}; }
this.worker.onmessage = ({ data }: MessageEvent) => { this.worker.onmessage = ({ data }: MessageEvent) => {
if (data === 'failed') { if (data === 'failed') {
this.stop(); this.stop()
this._debug('worker_failed', {}); // add context (from worker) this._debug('worker_failed', {}) // add context (from worker)
} else if (data === 'restart') { } else if (data === 'restart') {
this.stop(); this.stop()
this.start({ forceNew: true }); this.start({ forceNew: true })
} }
}; }
const alertWorker = () => { const alertWorker = () => {
if (this.worker) { if (this.worker) {
this.worker.postMessage(null); this.worker.postMessage(null)
} }
}; }
// keep better tactics, discard others? // keep better tactics, discard others?
this.attachEventListener(window, 'beforeunload', alertWorker, false); this.attachEventListener(window, 'beforeunload', alertWorker, false)
this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false); this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false)
// TODO: stop session after inactivity timeout (make configurable) // TODO: stop session after inactivity timeout (make configurable)
this.attachEventListener(document, 'visibilitychange', alertWorker, false); this.attachEventListener(document, 'visibilitychange', alertWorker, false)
} catch (e) { } catch (e) {
this._debug('worker_start', e); this._debug('worker_start', e)
} }
} }
@ -188,56 +186,56 @@ export default class App {
context, context,
error: `${e}`, error: `${e}`,
}), }),
}); })
} }
this.debug.error('OpenReplay error: ', context, e); this.debug.error('OpenReplay error: ', context, e)
} }
send(message: Message, urgent = false): void { send(message: Message, urgent = false): void {
if (this.activityState === ActivityState.NotActive) { if (this.activityState === ActivityState.NotActive) {
return; return
} }
this.messages.push(message); this.messages.push(message)
// TODO: commit on start if there were `urgent` sends; // TODO: commit on start if there were `urgent` sends;
// Clearify where urgent can be used for; // Clearify where urgent can be used for;
// Clearify workflow for each type of message in case it was sent before start // Clearify workflow for each type of message in case it was sent before start
// (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike) // (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike)
if (this.activityState === ActivityState.Active && urgent) { if (this.activityState === ActivityState.Active && urgent) {
this.commit(); this.commit()
} }
} }
private commit(): void { private commit(): void {
if (this.worker && this.messages.length) { if (this.worker && this.messages.length) {
this.messages.unshift(new Timestamp(timestamp())); this.messages.unshift(Timestamp(timestamp()))
this.worker.postMessage(this.messages); this.worker.postMessage(this.messages)
this.commitCallbacks.forEach((cb) => cb(this.messages)); this.commitCallbacks.forEach((cb) => cb(this.messages))
this.messages.length = 0; this.messages.length = 0
} }
} }
safe<T extends (...args: any[]) => void>(fn: T): T { safe<T extends (...args: any[]) => void>(fn: T): T {
const app = this; const app = this
return function (this: any, ...args: any) { return function (this: any, ...args: any) {
try { try {
fn.apply(this, args); fn.apply(this, args)
} catch (e) { } catch (e) {
app._debug('safe_fn_call', e); app._debug('safe_fn_call', e)
// time: timestamp(), // time: timestamp(),
// name: e.name, // name: e.name,
// message: e.message, // message: e.message,
// stack: e.stack // stack: e.stack
} }
} as any; // TODO: correct typing } as any // TODO: correct typing
} }
attachCommitCallback(cb: CommitCallback): void { attachCommitCallback(cb: CommitCallback): void {
this.commitCallbacks.push(cb); this.commitCallbacks.push(cb)
} }
attachStartCallback(cb: StartCallback): void { attachStartCallback(cb: StartCallback): void {
this.startCallbacks.push(cb); this.startCallbacks.push(cb)
} }
attachStopCallback(cb: () => any): void { attachStopCallback(cb: () => any): void {
this.stopCallbacks.push(cb); this.stopCallbacks.push(cb)
} }
attachEventListener( attachEventListener(
target: EventTarget, target: EventTarget,
@ -247,22 +245,22 @@ export default class App {
useCapture = true, useCapture = true,
): void { ): void {
if (useSafe) { if (useSafe) {
listener = this.safe(listener); listener = this.safe(listener)
} }
this.attachStartCallback(() => target.addEventListener(type, listener, useCapture)); this.attachStartCallback(() => target.addEventListener(type, listener, useCapture))
this.attachStopCallback(() => target.removeEventListener(type, listener, useCapture)); this.attachStopCallback(() => target.removeEventListener(type, listener, useCapture))
} }
// TODO: full correct semantic // TODO: full correct semantic
checkRequiredVersion(version: string): boolean { checkRequiredVersion(version: string): boolean {
const reqVer = version.split(/[.-]/); const reqVer = version.split(/[.-]/)
const ver = this.version.split(/[.-]/); const ver = this.version.split(/[.-]/)
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (Number(ver[i]) < Number(reqVer[i]) || isNaN(Number(ver[i])) || isNaN(Number(reqVer[i]))) { if (Number(ver[i]) < Number(reqVer[i]) || isNaN(Number(ver[i])) || isNaN(Number(reqVer[i]))) {
return false; return false
} }
} }
return true; return true
} }
private getStartInfo() { private getStartInfo() {
@ -273,97 +271,98 @@ export default class App {
timestamp: timestamp(), // shouldn't it be set once? timestamp: timestamp(), // shouldn't it be set once?
trackerVersion: this.version, trackerVersion: this.version,
isSnippet: this.options.__is_snippet, isSnippet: this.options.__is_snippet,
}; }
} }
getSessionInfo() { getSessionInfo() {
return { return {
...this.session.getInfo(), ...this.session.getInfo(),
...this.getStartInfo(), ...this.getStartInfo(),
}; }
} }
getSessionToken(): string | undefined { getSessionToken(): string | undefined {
const token = this.sessionStorage.getItem(this.options.session_token_key); const token = this.sessionStorage.getItem(this.options.session_token_key)
if (token !== null) { if (token !== null) {
return token; return token
} }
} }
getSessionID(): string | undefined { getSessionID(): string | undefined {
return this.session.getInfo().sessionID || undefined; return this.session.getInfo().sessionID || undefined
} }
getHost(): string { getHost(): string {
return new URL(this.options.ingestPoint).hostname; return new URL(this.options.ingestPoint).hostname
} }
getProjectKey(): string { getProjectKey(): string {
return this.projectKey; return this.projectKey
} }
getBaseHref(): string { getBaseHref(): string {
if (typeof this.options.resourceBaseHref === 'string') { if (typeof this.options.resourceBaseHref === 'string') {
return this.options.resourceBaseHref; return this.options.resourceBaseHref
} else if (typeof this.options.resourceBaseHref === 'object') { } else if (typeof this.options.resourceBaseHref === 'object') {
//switch between types //switch between types
} }
if (document.baseURI) { if (document.baseURI) {
return document.baseURI; return document.baseURI
} }
// IE only // IE only
return ( return (
document.head?.getElementsByTagName('base')[0]?.getAttribute('href') || document.head?.getElementsByTagName('base')[0]?.getAttribute('href') ||
location.origin + location.pathname location.origin + location.pathname
); )
} }
resolveResourceURL(resourceURL: string): string { resolveResourceURL(resourceURL: string): string {
const base = new URL(this.getBaseHref()); const base = new URL(this.getBaseHref())
base.pathname += '/' + new URL(resourceURL).pathname; base.pathname += '/' + new URL(resourceURL).pathname
base.pathname.replace(/\/+/g, '/'); base.pathname.replace(/\/+/g, '/')
return base.toString(); return base.toString()
} }
isServiceURL(url: string): boolean { isServiceURL(url: string): boolean {
return url.startsWith(this.options.ingestPoint); return url.startsWith(this.options.ingestPoint)
} }
active(): boolean { active(): boolean {
return this.activityState === ActivityState.Active; return this.activityState === ActivityState.Active
} }
resetNextPageSession(flag: boolean) { resetNextPageSession(flag: boolean) {
if (flag) { if (flag) {
this.sessionStorage.setItem(this.options.session_reset_key, 't'); this.sessionStorage.setItem(this.options.session_reset_key, 't')
} else { } else {
this.sessionStorage.removeItem(this.options.session_reset_key); this.sessionStorage.removeItem(this.options.session_reset_key)
} }
} }
private _start(startOpts: StartOptions): Promise<StartPromiseReturn> { private _start(startOpts: StartOptions): Promise<StartPromiseReturn> {
if (!this.worker) { if (!this.worker) {
return Promise.resolve(UnsuccessfulStart('No worker found: perhaps, CSP is not set.')); return Promise.resolve(UnsuccessfulStart('No worker found: perhaps, CSP is not set.'))
} }
if (this.activityState !== ActivityState.NotActive) { if (this.activityState !== ActivityState.NotActive) {
return Promise.resolve( return Promise.resolve(
UnsuccessfulStart( UnsuccessfulStart(
'OpenReplay: trying to call `start()` on the instance that has been started already.', 'OpenReplay: trying to call `start()` on the instance that has been started already.',
), ),
); )
} }
this.activityState = ActivityState.Starting; this.activityState = ActivityState.Starting
let pageNo = 0; let pageNo = 0
const pageNoStr = this.sessionStorage.getItem(this.options.session_pageno_key); const pageNoStr = this.sessionStorage.getItem(this.options.session_pageno_key)
if (pageNoStr != null) { if (pageNoStr != null) {
pageNo = parseInt(pageNoStr); pageNo = parseInt(pageNoStr)
pageNo++; pageNo++
} }
this.sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString()); this.sessionStorage.setItem(this.options.session_pageno_key, pageNo.toString())
const startInfo = this.getStartInfo(); const startInfo = this.getStartInfo()
const startWorkerMsg: WorkerMessageData = { const startWorkerMsg: WorkerMessageData = {
type: 'start', type: 'start',
pageNo, pageNo,
ingestPoint: this.options.ingestPoint, ingestPoint: this.options.ingestPoint,
timestamp: startInfo.timestamp, timestamp: startInfo.timestamp,
url: document.URL,
connAttemptCount: this.options.connAttemptCount, connAttemptCount: this.options.connAttemptCount,
connAttemptGap: this.options.connAttemptGap, connAttemptGap: this.options.connAttemptGap,
}; }
this.worker.postMessage(startWorkerMsg); this.worker.postMessage(startWorkerMsg)
this.session.update({ this.session.update({
// TODO: transparent "session" module logic AND explicit internal api for plugins. // TODO: transparent "session" module logic AND explicit internal api for plugins.
@ -371,10 +370,10 @@ export default class App {
// (for the case of internal .start() calls, like on "restart" webworker signal or assistent connection in tracker-assist ) // (for the case of internal .start() calls, like on "restart" webworker signal or assistent connection in tracker-assist )
metadata: startOpts.metadata || this.session.getInfo().metadata, metadata: startOpts.metadata || this.session.getInfo().metadata,
userID: startOpts.userID, userID: startOpts.userID,
}); })
const sReset = this.sessionStorage.getItem(this.options.session_reset_key); const sReset = this.sessionStorage.getItem(this.options.session_reset_key)
this.sessionStorage.removeItem(this.options.session_reset_key); this.sessionStorage.removeItem(this.options.session_reset_key)
return window return window
.fetch(this.options.ingestPoint + '/v1/web/start', { .fetch(this.options.ingestPoint + '/v1/web/start', {
@ -393,7 +392,7 @@ export default class App {
}) })
.then((r) => { .then((r) => {
if (r.status === 200) { if (r.status === 200) {
return r.json(); return r.json()
} else { } else {
return r return r
.text() .text()
@ -401,96 +400,96 @@ export default class App {
text === CANCELED text === CANCELED
? Promise.reject(CANCELED) ? Promise.reject(CANCELED)
: Promise.reject(`Server error: ${r.status}. ${text}`), : Promise.reject(`Server error: ${r.status}. ${text}`),
); )
} }
}) })
.then((r) => { .then((r) => {
if (!this.worker) { if (!this.worker) {
return Promise.reject('no worker found after start request (this might not happen)'); return Promise.reject('no worker found after start request (this might not happen)')
} }
const { token, userUUID, sessionID, beaconSizeLimit } = r; const { token, userUUID, sessionID, beaconSizeLimit } = r
if ( if (
typeof token !== 'string' || typeof token !== 'string' ||
typeof userUUID !== 'string' || typeof userUUID !== 'string' ||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined') (typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')
) { ) {
return Promise.reject(`Incorrect server response: ${JSON.stringify(r)}`); return Promise.reject(`Incorrect server response: ${JSON.stringify(r)}`)
} }
this.sessionStorage.setItem(this.options.session_token_key, token); this.sessionStorage.setItem(this.options.session_token_key, token)
this.localStorage.setItem(this.options.local_uuid_key, userUUID); this.localStorage.setItem(this.options.local_uuid_key, userUUID)
this.session.update({ sessionID }); // TODO: no no-explicit 'any' this.session.update({ sessionID }) // TODO: no no-explicit 'any'
const startWorkerMsg: WorkerMessageData = { const startWorkerMsg: WorkerMessageData = {
type: 'auth', type: 'auth',
token, token,
beaconSizeLimit, beaconSizeLimit,
}; }
this.worker.postMessage(startWorkerMsg); this.worker.postMessage(startWorkerMsg)
this.activityState = ActivityState.Active; this.activityState = ActivityState.Active
const onStartInfo = { sessionToken: token, userUUID, sessionID }; const onStartInfo = { sessionToken: token, userUUID, sessionID }
this.startCallbacks.forEach((cb) => cb(onStartInfo)); // TODO: start as early as possible (before receiving the token) this.startCallbacks.forEach((cb) => cb(onStartInfo)) // TODO: start as early as possible (before receiving the token)
this.observer.observe(); this.observer.observe()
this.ticker.start(); this.ticker.start()
this.notify.log('OpenReplay tracking started.'); this.notify.log('OpenReplay tracking started.')
// get rid of onStart ? // get rid of onStart ?
if (typeof this.options.onStart === 'function') { if (typeof this.options.onStart === 'function') {
this.options.onStart(onStartInfo); this.options.onStart(onStartInfo)
} }
return SuccessfulStart(onStartInfo); return SuccessfulStart(onStartInfo)
}) })
.catch((reason) => { .catch((reason) => {
this.sessionStorage.removeItem(this.options.session_token_key); this.sessionStorage.removeItem(this.options.session_token_key)
this.stop(); this.stop()
if (reason === CANCELED) { if (reason === CANCELED) {
return UnsuccessfulStart(CANCELED); return UnsuccessfulStart(CANCELED)
} }
this.notify.log('OpenReplay was unable to start. ', reason); this.notify.log('OpenReplay was unable to start. ', reason)
this._debug('session_start', reason); this._debug('session_start', reason)
return UnsuccessfulStart(START_ERROR); return UnsuccessfulStart(START_ERROR)
}); })
} }
start(options: StartOptions = {}): Promise<StartPromiseReturn> { start(options: StartOptions = {}): Promise<StartPromiseReturn> {
if (!document.hidden) { if (!document.hidden) {
return this._start(options); return this._start(options)
} else { } else {
return new Promise((resolve) => { return new Promise((resolve) => {
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (!document.hidden) { if (!document.hidden) {
document.removeEventListener('visibilitychange', onVisibilityChange); document.removeEventListener('visibilitychange', onVisibilityChange)
resolve(this._start(options)); resolve(this._start(options))
} }
}; }
document.addEventListener('visibilitychange', onVisibilityChange); document.addEventListener('visibilitychange', onVisibilityChange)
}); })
} }
} }
stop(calledFromAPI = false, restarting = false): void { stop(calledFromAPI = false, restarting = false): void {
if (this.activityState !== ActivityState.NotActive) { if (this.activityState !== ActivityState.NotActive) {
try { try {
this.sanitizer.clear(); this.sanitizer.clear()
this.observer.disconnect(); this.observer.disconnect()
this.nodes.clear(); this.nodes.clear()
this.ticker.stop(); this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb()); this.stopCallbacks.forEach((cb) => cb())
if (calledFromAPI) { if (calledFromAPI) {
this.session.reset(); this.session.reset()
} }
this.notify.log('OpenReplay tracking stopped.'); this.notify.log('OpenReplay tracking stopped.')
if (this.worker && !restarting) { if (this.worker && !restarting) {
this.worker.postMessage('stop'); this.worker.postMessage('stop')
} }
} finally { } finally {
this.activityState = ActivityState.NotActive; this.activityState = ActivityState.NotActive
} }
} }
} }
restart() { restart() {
this.stop(false, true); this.stop(false, true)
this.start({ forceNew: false }); this.start({ forceNew: false })
} }
} }

View file

@ -4,35 +4,35 @@ export const LogLevel = {
Warnings: 3, Warnings: 3,
Errors: 2, Errors: 2,
Silent: 0, Silent: 0,
} as const; } as const
type LogLevel = typeof LogLevel[keyof typeof LogLevel]; type LogLevel = typeof LogLevel[keyof typeof LogLevel]
type CustomLevel = { type CustomLevel = {
error: boolean; error: boolean
warn: boolean; warn: boolean
log: boolean; log: boolean
}; }
function IsCustomLevel(l: LogLevel | CustomLevel): l is CustomLevel { function IsCustomLevel(l: LogLevel | CustomLevel): l is CustomLevel {
return typeof l === 'object'; return typeof l === 'object'
} }
interface _Options { interface _Options {
level: LogLevel | CustomLevel; level: LogLevel | CustomLevel
messages?: number[]; messages?: number[]
} }
export type Options = true | _Options | LogLevel; export type Options = true | _Options | LogLevel
export default class Logger { export default class Logger {
private readonly options: _Options; private readonly options: _Options
constructor(options: Options = LogLevel.Silent) { constructor(options: Options = LogLevel.Silent) {
this.options = this.options =
options === true options === true
? { level: LogLevel.Verbose } ? { level: LogLevel.Verbose }
: typeof options === 'number' : typeof options === 'number'
? { level: options } ? { level: options }
: options; : options
} }
log(...args: any) { log(...args: any) {
if ( if (
@ -40,7 +40,7 @@ export default class Logger {
? this.options.level.log ? this.options.level.log
: this.options.level >= LogLevel.Log : this.options.level >= LogLevel.Log
) { ) {
console.log(...args); console.log(...args)
} }
} }
warn(...args: any) { warn(...args: any) {
@ -49,7 +49,7 @@ export default class Logger {
? this.options.level.warn ? this.options.level.warn
: this.options.level >= LogLevel.Warnings : this.options.level >= LogLevel.Warnings
) { ) {
console.warn(...args); console.warn(...args)
} }
} }
error(...args: any) { error(...args: any) {
@ -58,7 +58,7 @@ export default class Logger {
? this.options.level.error ? this.options.level.error
: this.options.level >= LogLevel.Errors : this.options.level >= LogLevel.Errors
) { ) {
console.error(...args); console.error(...args)
} }
} }
} }

View file

@ -0,0 +1,648 @@
// Auto-generated, do not edit
import * as Messages from '../../common/messages.gen.js'
export { default } from '../../common/messages.gen.js'
export function BatchMetadata(
version: number,
pageNo: number,
firstIndex: number,
timestamp: number,
location: string,
): Messages.BatchMetadata {
return [
Messages.Type.BatchMetadata,
version,
pageNo,
firstIndex,
timestamp,
location,
]
}
export function PartitionedMessage(
partNo: number,
partTotal: number,
): Messages.PartitionedMessage {
return [
Messages.Type.PartitionedMessage,
partNo,
partTotal,
]
}
export function Timestamp(
timestamp: number,
): Messages.Timestamp {
return [
Messages.Type.Timestamp,
timestamp,
]
}
export function SetPageLocation(
url: string,
referrer: string,
navigationStart: number,
): Messages.SetPageLocation {
return [
Messages.Type.SetPageLocation,
url,
referrer,
navigationStart,
]
}
export function SetViewportSize(
width: number,
height: number,
): Messages.SetViewportSize {
return [
Messages.Type.SetViewportSize,
width,
height,
]
}
export function SetViewportScroll(
x: number,
y: number,
): Messages.SetViewportScroll {
return [
Messages.Type.SetViewportScroll,
x,
y,
]
}
export function CreateDocument(
): Messages.CreateDocument {
return [
Messages.Type.CreateDocument,
]
}
export function CreateElementNode(
id: number,
parentID: number,
index: number,
tag: string,
svg: boolean,
): Messages.CreateElementNode {
return [
Messages.Type.CreateElementNode,
id,
parentID,
index,
tag,
svg,
]
}
export function CreateTextNode(
id: number,
parentID: number,
index: number,
): Messages.CreateTextNode {
return [
Messages.Type.CreateTextNode,
id,
parentID,
index,
]
}
export function MoveNode(
id: number,
parentID: number,
index: number,
): Messages.MoveNode {
return [
Messages.Type.MoveNode,
id,
parentID,
index,
]
}
export function RemoveNode(
id: number,
): Messages.RemoveNode {
return [
Messages.Type.RemoveNode,
id,
]
}
export function SetNodeAttribute(
id: number,
name: string,
value: string,
): Messages.SetNodeAttribute {
return [
Messages.Type.SetNodeAttribute,
id,
name,
value,
]
}
export function RemoveNodeAttribute(
id: number,
name: string,
): Messages.RemoveNodeAttribute {
return [
Messages.Type.RemoveNodeAttribute,
id,
name,
]
}
export function SetNodeData(
id: number,
data: string,
): Messages.SetNodeData {
return [
Messages.Type.SetNodeData,
id,
data,
]
}
export function SetNodeScroll(
id: number,
x: number,
y: number,
): Messages.SetNodeScroll {
return [
Messages.Type.SetNodeScroll,
id,
x,
y,
]
}
export function SetInputTarget(
id: number,
label: string,
): Messages.SetInputTarget {
return [
Messages.Type.SetInputTarget,
id,
label,
]
}
export function SetInputValue(
id: number,
value: string,
mask: number,
): Messages.SetInputValue {
return [
Messages.Type.SetInputValue,
id,
value,
mask,
]
}
export function SetInputChecked(
id: number,
checked: boolean,
): Messages.SetInputChecked {
return [
Messages.Type.SetInputChecked,
id,
checked,
]
}
export function MouseMove(
x: number,
y: number,
): Messages.MouseMove {
return [
Messages.Type.MouseMove,
x,
y,
]
}
export function ConsoleLog(
level: string,
value: string,
): Messages.ConsoleLog {
return [
Messages.Type.ConsoleLog,
level,
value,
]
}
export function PageLoadTiming(
requestStart: number,
responseStart: number,
responseEnd: number,
domContentLoadedEventStart: number,
domContentLoadedEventEnd: number,
loadEventStart: number,
loadEventEnd: number,
firstPaint: number,
firstContentfulPaint: number,
): Messages.PageLoadTiming {
return [
Messages.Type.PageLoadTiming,
requestStart,
responseStart,
responseEnd,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
loadEventEnd,
firstPaint,
firstContentfulPaint,
]
}
export function PageRenderTiming(
speedIndex: number,
visuallyComplete: number,
timeToInteractive: number,
): Messages.PageRenderTiming {
return [
Messages.Type.PageRenderTiming,
speedIndex,
visuallyComplete,
timeToInteractive,
]
}
export function JSException(
name: string,
message: string,
payload: string,
): Messages.JSException {
return [
Messages.Type.JSException,
name,
message,
payload,
]
}
export function RawCustomEvent(
name: string,
payload: string,
): Messages.RawCustomEvent {
return [
Messages.Type.RawCustomEvent,
name,
payload,
]
}
export function UserID(
id: string,
): Messages.UserID {
return [
Messages.Type.UserID,
id,
]
}
export function UserAnonymousID(
id: string,
): Messages.UserAnonymousID {
return [
Messages.Type.UserAnonymousID,
id,
]
}
export function Metadata(
key: string,
value: string,
): Messages.Metadata {
return [
Messages.Type.Metadata,
key,
value,
]
}
export function CSSInsertRule(
id: number,
rule: string,
index: number,
): Messages.CSSInsertRule {
return [
Messages.Type.CSSInsertRule,
id,
rule,
index,
]
}
export function CSSDeleteRule(
id: number,
index: number,
): Messages.CSSDeleteRule {
return [
Messages.Type.CSSDeleteRule,
id,
index,
]
}
export function Fetch(
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
): Messages.Fetch {
return [
Messages.Type.Fetch,
method,
url,
request,
response,
status,
timestamp,
duration,
]
}
export function Profiler(
name: string,
duration: number,
args: string,
result: string,
): Messages.Profiler {
return [
Messages.Type.Profiler,
name,
duration,
args,
result,
]
}
export function OTable(
key: string,
value: string,
): Messages.OTable {
return [
Messages.Type.OTable,
key,
value,
]
}
export function StateAction(
type: string,
): Messages.StateAction {
return [
Messages.Type.StateAction,
type,
]
}
export function Redux(
action: string,
state: string,
duration: number,
): Messages.Redux {
return [
Messages.Type.Redux,
action,
state,
duration,
]
}
export function Vuex(
mutation: string,
state: string,
): Messages.Vuex {
return [
Messages.Type.Vuex,
mutation,
state,
]
}
export function MobX(
type: string,
payload: string,
): Messages.MobX {
return [
Messages.Type.MobX,
type,
payload,
]
}
export function NgRx(
action: string,
state: string,
duration: number,
): Messages.NgRx {
return [
Messages.Type.NgRx,
action,
state,
duration,
]
}
export function GraphQL(
operationKind: string,
operationName: string,
variables: string,
response: string,
): Messages.GraphQL {
return [
Messages.Type.GraphQL,
operationKind,
operationName,
variables,
response,
]
}
export function PerformanceTrack(
frames: number,
ticks: number,
totalJSHeapSize: number,
usedJSHeapSize: number,
): Messages.PerformanceTrack {
return [
Messages.Type.PerformanceTrack,
frames,
ticks,
totalJSHeapSize,
usedJSHeapSize,
]
}
export function ResourceTiming(
timestamp: number,
duration: number,
ttfb: number,
headerSize: number,
encodedBodySize: number,
decodedBodySize: number,
url: string,
initiator: string,
): Messages.ResourceTiming {
return [
Messages.Type.ResourceTiming,
timestamp,
duration,
ttfb,
headerSize,
encodedBodySize,
decodedBodySize,
url,
initiator,
]
}
export function ConnectionInformation(
downlink: number,
type: string,
): Messages.ConnectionInformation {
return [
Messages.Type.ConnectionInformation,
downlink,
type,
]
}
export function SetPageVisibility(
hidden: boolean,
): Messages.SetPageVisibility {
return [
Messages.Type.SetPageVisibility,
hidden,
]
}
export function LongTask(
timestamp: number,
duration: number,
context: number,
containerType: number,
containerSrc: string,
containerId: string,
containerName: string,
): Messages.LongTask {
return [
Messages.Type.LongTask,
timestamp,
duration,
context,
containerType,
containerSrc,
containerId,
containerName,
]
}
export function SetNodeAttributeURLBased(
id: number,
name: string,
value: string,
baseURL: string,
): Messages.SetNodeAttributeURLBased {
return [
Messages.Type.SetNodeAttributeURLBased,
id,
name,
value,
baseURL,
]
}
export function SetCSSDataURLBased(
id: number,
data: string,
baseURL: string,
): Messages.SetCSSDataURLBased {
return [
Messages.Type.SetCSSDataURLBased,
id,
data,
baseURL,
]
}
export function TechnicalInfo(
type: string,
value: string,
): Messages.TechnicalInfo {
return [
Messages.Type.TechnicalInfo,
type,
value,
]
}
export function CustomIssue(
name: string,
payload: string,
): Messages.CustomIssue {
return [
Messages.Type.CustomIssue,
name,
payload,
]
}
export function CSSInsertRuleURLBased(
id: number,
rule: string,
index: number,
baseURL: string,
): Messages.CSSInsertRuleURLBased {
return [
Messages.Type.CSSInsertRuleURLBased,
id,
rule,
index,
baseURL,
]
}
export function MouseClick(
id: number,
hesitationTime: number,
label: string,
selector: string,
): Messages.MouseClick {
return [
Messages.Type.MouseClick,
id,
hesitationTime,
label,
selector,
]
}
export function CreateIFrameDocument(
frameID: number,
id: number,
): Messages.CreateIFrameDocument {
return [
Messages.Type.CreateIFrameDocument,
frameID,
id,
]
}

View file

@ -0,0 +1,334 @@
// Auto-generated, do not edit
import * as Messages from '../../common/messages.gen.js'
export { default } from '../../common/messages.gen.js'
export function BatchMetadata(
version: number,
pageNo: number,
firstIndex: number,
timestamp: number,
location: string,
): Messages.BatchMetadata {
return [Messages.Type.BatchMetadata, version, pageNo, firstIndex, timestamp, location]
}
export function PartitionedMessage(partNo: number, partTotal: number): Messages.PartitionedMessage {
return [Messages.Type.PartitionedMessage, partNo, partTotal]
}
export function Timestamp(timestamp: number): Messages.Timestamp {
return [Messages.Type.Timestamp, timestamp]
}
export function SetPageLocation(
url: string,
referrer: string,
navigationStart: number,
): Messages.SetPageLocation {
return [Messages.Type.SetPageLocation, url, referrer, navigationStart]
}
export function SetViewportSize(width: number, height: number): Messages.SetViewportSize {
return [Messages.Type.SetViewportSize, width, height]
}
export function SetViewportScroll(x: number, y: number): Messages.SetViewportScroll {
return [Messages.Type.SetViewportScroll, x, y]
}
export function CreateDocument(): Messages.CreateDocument {
return [Messages.Type.CreateDocument]
}
export function CreateElementNode(
id: number,
parentID: number,
index: number,
tag: string,
svg: boolean,
): Messages.CreateElementNode {
return [Messages.Type.CreateElementNode, id, parentID, index, tag, svg]
}
export function CreateTextNode(
id: number,
parentID: number,
index: number,
): Messages.CreateTextNode {
return [Messages.Type.CreateTextNode, id, parentID, index]
}
export function MoveNode(id: number, parentID: number, index: number): Messages.MoveNode {
return [Messages.Type.MoveNode, id, parentID, index]
}
export function RemoveNode(id: number): Messages.RemoveNode {
return [Messages.Type.RemoveNode, id]
}
export function SetNodeAttribute(
id: number,
name: string,
value: string,
): Messages.SetNodeAttribute {
return [Messages.Type.SetNodeAttribute, id, name, value]
}
export function RemoveNodeAttribute(id: number, name: string): Messages.RemoveNodeAttribute {
return [Messages.Type.RemoveNodeAttribute, id, name]
}
export function SetNodeData(id: number, data: string): Messages.SetNodeData {
return [Messages.Type.SetNodeData, id, data]
}
export function SetNodeScroll(id: number, x: number, y: number): Messages.SetNodeScroll {
return [Messages.Type.SetNodeScroll, id, x, y]
}
export function SetInputTarget(id: number, label: string): Messages.SetInputTarget {
return [Messages.Type.SetInputTarget, id, label]
}
export function SetInputValue(id: number, value: string, mask: number): Messages.SetInputValue {
return [Messages.Type.SetInputValue, id, value, mask]
}
export function SetInputChecked(id: number, checked: boolean): Messages.SetInputChecked {
return [Messages.Type.SetInputChecked, id, checked]
}
export function MouseMove(x: number, y: number): Messages.MouseMove {
return [Messages.Type.MouseMove, x, y]
}
export function ConsoleLog(level: string, value: string): Messages.ConsoleLog {
return [Messages.Type.ConsoleLog, level, value]
}
export function PageLoadTiming(
requestStart: number,
responseStart: number,
responseEnd: number,
domContentLoadedEventStart: number,
domContentLoadedEventEnd: number,
loadEventStart: number,
loadEventEnd: number,
firstPaint: number,
firstContentfulPaint: number,
): Messages.PageLoadTiming {
return [
Messages.Type.PageLoadTiming,
requestStart,
responseStart,
responseEnd,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
loadEventEnd,
firstPaint,
firstContentfulPaint,
]
}
export function PageRenderTiming(
speedIndex: number,
visuallyComplete: number,
timeToInteractive: number,
): Messages.PageRenderTiming {
return [Messages.Type.PageRenderTiming, speedIndex, visuallyComplete, timeToInteractive]
}
export function JSException(name: string, message: string, payload: string): Messages.JSException {
return [Messages.Type.JSException, name, message, payload]
}
export function RawCustomEvent(name: string, payload: string): Messages.RawCustomEvent {
return [Messages.Type.RawCustomEvent, name, payload]
}
export function UserID(id: string): Messages.UserID {
return [Messages.Type.UserID, id]
}
export function UserAnonymousID(id: string): Messages.UserAnonymousID {
return [Messages.Type.UserAnonymousID, id]
}
export function Metadata(key: string, value: string): Messages.Metadata {
return [Messages.Type.Metadata, key, value]
}
export function CSSInsertRule(id: number, rule: string, index: number): Messages.CSSInsertRule {
return [Messages.Type.CSSInsertRule, id, rule, index]
}
export function CSSDeleteRule(id: number, index: number): Messages.CSSDeleteRule {
return [Messages.Type.CSSDeleteRule, id, index]
}
export function Fetch(
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
): Messages.Fetch {
return [Messages.Type.Fetch, method, url, request, response, status, timestamp, duration]
}
export function Profiler(
name: string,
duration: number,
args: string,
result: string,
): Messages.Profiler {
return [Messages.Type.Profiler, name, duration, args, result]
}
export function OTable(key: string, value: string): Messages.OTable {
return [Messages.Type.OTable, key, value]
}
export function StateAction(type: string): Messages.StateAction {
return [Messages.Type.StateAction, type]
}
export function Redux(action: string, state: string, duration: number): Messages.Redux {
return [Messages.Type.Redux, action, state, duration]
}
export function Vuex(mutation: string, state: string): Messages.Vuex {
return [Messages.Type.Vuex, mutation, state]
}
export function MobX(type: string, payload: string): Messages.MobX {
return [Messages.Type.MobX, type, payload]
}
export function NgRx(action: string, state: string, duration: number): Messages.NgRx {
return [Messages.Type.NgRx, action, state, duration]
}
export function GraphQL(
operationKind: string,
operationName: string,
variables: string,
response: string,
): Messages.GraphQL {
return [Messages.Type.GraphQL, operationKind, operationName, variables, response]
}
export function PerformanceTrack(
frames: number,
ticks: number,
totalJSHeapSize: number,
usedJSHeapSize: number,
): Messages.PerformanceTrack {
return [Messages.Type.PerformanceTrack, frames, ticks, totalJSHeapSize, usedJSHeapSize]
}
export function ResourceTiming(
timestamp: number,
duration: number,
ttfb: number,
headerSize: number,
encodedBodySize: number,
decodedBodySize: number,
url: string,
initiator: string,
): Messages.ResourceTiming {
return [
Messages.Type.ResourceTiming,
timestamp,
duration,
ttfb,
headerSize,
encodedBodySize,
decodedBodySize,
url,
initiator,
]
}
export function ConnectionInformation(
downlink: number,
type: string,
): Messages.ConnectionInformation {
return [Messages.Type.ConnectionInformation, downlink, type]
}
export function SetPageVisibility(hidden: boolean): Messages.SetPageVisibility {
return [Messages.Type.SetPageVisibility, hidden]
}
export function LongTask(
timestamp: number,
duration: number,
context: number,
containerType: number,
containerSrc: string,
containerId: string,
containerName: string,
): Messages.LongTask {
return [
Messages.Type.LongTask,
timestamp,
duration,
context,
containerType,
containerSrc,
containerId,
containerName,
]
}
export function SetNodeAttributeURLBased(
id: number,
name: string,
value: string,
baseURL: string,
): Messages.SetNodeAttributeURLBased {
return [Messages.Type.SetNodeAttributeURLBased, id, name, value, baseURL]
}
export function SetCSSDataURLBased(
id: number,
data: string,
baseURL: string,
): Messages.SetCSSDataURLBased {
return [Messages.Type.SetCSSDataURLBased, id, data, baseURL]
}
export function TechnicalInfo(type: string, value: string): Messages.TechnicalInfo {
return [Messages.Type.TechnicalInfo, type, value]
}
export function CustomIssue(name: string, payload: string): Messages.CustomIssue {
return [Messages.Type.CustomIssue, name, payload]
}
export function CSSInsertRuleURLBased(
id: number,
rule: string,
index: number,
baseURL: string,
): Messages.CSSInsertRuleURLBased {
return [Messages.Type.CSSInsertRuleURLBased, id, rule, index, baseURL]
}
export function MouseClick(
id: number,
hesitationTime: number,
label: string,
selector: string,
): Messages.MouseClick {
return [Messages.Type.MouseClick, id, hesitationTime, label, selector]
}
export function CreateIFrameDocument(frameID: number, id: number): Messages.CreateIFrameDocument {
return [Messages.Type.CreateIFrameDocument, frameID, id]
}

View file

@ -1,53 +1,53 @@
type NodeCallback = (node: Node, isStart: boolean) => void; type NodeCallback = (node: Node, isStart: boolean) => void
type ElementListener = [string, EventListener]; type ElementListener = [string, EventListener]
export default class Nodes { export default class Nodes {
private nodes: Array<Node | void> = []; private nodes: Array<Node | void> = []
private readonly nodeCallbacks: Array<NodeCallback> = []; private readonly nodeCallbacks: Array<NodeCallback> = []
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map(); private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
constructor(private readonly node_id: string) {} constructor(private readonly node_id: string) {}
attachNodeCallback(nodeCallback: NodeCallback): void { attachNodeCallback(nodeCallback: NodeCallback): void {
this.nodeCallbacks.push(nodeCallback); this.nodeCallbacks.push(nodeCallback)
} }
attachElementListener(type: string, node: Element, elementListener: EventListener): void { attachElementListener(type: string, node: Element, elementListener: EventListener): void {
const id = this.getID(node); const id = this.getID(node)
if (id === undefined) { if (id === undefined) {
return; return
} }
node.addEventListener(type, elementListener); node.addEventListener(type, elementListener)
let listeners = this.elementListeners.get(id); let listeners = this.elementListeners.get(id)
if (listeners === undefined) { if (listeners === undefined) {
listeners = []; listeners = []
this.elementListeners.set(id, listeners); this.elementListeners.set(id, listeners)
return; return
} }
listeners.push([type, elementListener]); listeners.push([type, elementListener])
} }
registerNode(node: Node): [id: number, isNew: boolean] { registerNode(node: Node): [id: number, isNew: boolean] {
let id: number = (node as any)[this.node_id]; let id: number = (node as any)[this.node_id]
const isNew = id === undefined; const isNew = id === undefined
if (isNew) { if (isNew) {
id = this.nodes.length; id = this.nodes.length
this.nodes[id] = node; this.nodes[id] = node
(node as any)[this.node_id] = id; ;(node as any)[this.node_id] = id
} }
return [id, isNew]; return [id, isNew]
} }
unregisterNode(node: Node): number | undefined { unregisterNode(node: Node): number | undefined {
const id = (node as any)[this.node_id]; const id = (node as any)[this.node_id]
if (id !== undefined) { if (id !== undefined) {
delete (node as any)[this.node_id]; delete (node as any)[this.node_id]
delete this.nodes[id]; delete this.nodes[id]
const listeners = this.elementListeners.get(id); const listeners = this.elementListeners.get(id)
if (listeners !== undefined) { if (listeners !== undefined) {
this.elementListeners.delete(id); this.elementListeners.delete(id)
listeners.forEach((listener) => node.removeEventListener(listener[0], listener[1])); listeners.forEach((listener) => node.removeEventListener(listener[0], listener[1]))
} }
} }
return id; return id
} }
cleanTree() { cleanTree() {
// sadly we keep empty items in array here resulting in some memory still being used // sadly we keep empty items in array here resulting in some memory still being used
@ -55,30 +55,30 @@ export default class Nodes {
// plus we keep our index positions for new/alive nodes // plus we keep our index positions for new/alive nodes
// performance test: 3ms for 30k nodes with 17k dead ones // performance test: 3ms for 30k nodes with 17k dead ones
for (let i = 0; i < this.nodes.length; i++) { for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i]; const node = this.nodes[i]
if (node && !document.contains(node)) { if (node && !document.contains(node)) {
this.unregisterNode(node); this.unregisterNode(node)
} }
} }
} }
callNodeCallbacks(node: Node, isStart: boolean): void { callNodeCallbacks(node: Node, isStart: boolean): void {
this.nodeCallbacks.forEach((cb) => cb(node, isStart)); this.nodeCallbacks.forEach((cb) => cb(node, isStart))
} }
getID(node: Node): number | undefined { getID(node: Node): number | undefined {
return (node as any)[this.node_id]; return (node as any)[this.node_id]
} }
getNode(id: number) { getNode(id: number) {
return this.nodes[id]; return this.nodes[id]
} }
clear(): void { clear(): void {
for (let id = 0; id < this.nodes.length; id++) { for (let id = 0; id < this.nodes.length; id++) {
const node = this.nodes[id]; const node = this.nodes[id]
if (node === undefined) { if (node === undefined) {
continue; continue
} }
this.unregisterNode(node); this.unregisterNode(node)
} }
this.nodes.length = 0; this.nodes.length = 0
} }
} }

View file

@ -1,20 +1,20 @@
import Observer from './observer.js'; import Observer from './observer.js'
import { CreateIFrameDocument } from '../../../common/messages.js'; import { CreateIFrameDocument } from '../messages.gen.js'
export default class IFrameObserver extends Observer { export default class IFrameObserver extends Observer {
observe(iframe: HTMLIFrameElement) { observe(iframe: HTMLIFrameElement) {
const doc = iframe.contentDocument; const doc = iframe.contentDocument
const hostID = this.app.nodes.getID(iframe); const hostID = this.app.nodes.getID(iframe)
if (!doc || hostID === undefined) { if (!doc || hostID === undefined) {
return; return
} //log TODO common app.logger } //log TODO common app.logger
// Have to observe document, because the inner <html> might be changed // Have to observe document, because the inner <html> might be changed
this.observeRoot(doc, (docID) => { this.observeRoot(doc, (docID) => {
if (docID === undefined) { if (docID === undefined) {
console.log('OpenReplay: Iframe document not bound'); console.log('OpenReplay: Iframe document not bound')
return; return
} }
this.app.send(CreateIFrameDocument(hostID, docID)); this.app.send(CreateIFrameDocument(hostID, docID))
}); })
} }
} }

View file

@ -8,33 +8,33 @@ import {
CreateElementNode, CreateElementNode,
MoveNode, MoveNode,
RemoveNode, RemoveNode,
} from '../../../common/messages.js'; } from '../messages.gen.js'
import App from '../index.js'; import App from '../index.js'
import { isRootNode, isTextNode, isElementNode, isSVGElement, hasTag } from '../guards.js'; import { isRootNode, isTextNode, isElementNode, isSVGElement, hasTag } from '../guards.js'
function isIgnored(node: Node): boolean { function isIgnored(node: Node): boolean {
if (isTextNode(node)) { if (isTextNode(node)) {
return false; return false
} }
if (!isElementNode(node)) { if (!isElementNode(node)) {
return true; return true
} }
const tag = node.tagName.toUpperCase(); const tag = node.tagName.toUpperCase()
if (tag === 'LINK') { if (tag === 'LINK') {
const rel = node.getAttribute('rel'); const rel = node.getAttribute('rel')
const as = node.getAttribute('as'); const as = node.getAttribute('as')
return !(rel?.includes('stylesheet') || as === 'style' || as === 'font'); return !(rel?.includes('stylesheet') || as === 'style' || as === 'font')
} }
return ( return (
tag === 'SCRIPT' || tag === 'NOSCRIPT' || tag === 'META' || tag === 'TITLE' || tag === 'BASE' tag === 'SCRIPT' || tag === 'NOSCRIPT' || tag === 'META' || tag === 'TITLE' || tag === 'BASE'
); )
} }
function isObservable(node: Node): boolean { function isObservable(node: Node): boolean {
if (isRootNode(node)) { if (isRootNode(node)) {
return true; return true
} }
return !isIgnored(node); return !isIgnored(node)
} }
/* /*
@ -57,84 +57,84 @@ enum RecentsType {
} }
export default abstract class Observer { export default abstract class Observer {
private readonly observer: MutationObserver; private readonly observer: MutationObserver
private readonly commited: Array<boolean | undefined> = []; private readonly commited: Array<boolean | undefined> = []
private readonly recents: Map<number, RecentsType> = new Map(); private readonly recents: Map<number, RecentsType> = new Map()
private readonly indexes: Array<number> = []; private readonly indexes: Array<number> = []
private readonly attributesMap: Map<number, Set<string>> = new Map(); private readonly attributesMap: Map<number, Set<string>> = new Map()
private readonly textSet: Set<number> = new Set(); private readonly textSet: Set<number> = new Set()
constructor(protected readonly app: App, protected readonly isTopContext = false) { constructor(protected readonly app: App, protected readonly isTopContext = false) {
this.observer = new MutationObserver( this.observer = new MutationObserver(
this.app.safe((mutations) => { this.app.safe((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
// mutations order is sequential // mutations order is sequential
const target = mutation.target; const target = mutation.target
const type = mutation.type; const type = mutation.type
if (!isObservable(target)) { if (!isObservable(target)) {
continue; continue
} }
if (type === 'childList') { if (type === 'childList') {
for (let i = 0; i < mutation.removedNodes.length; i++) { for (let i = 0; i < mutation.removedNodes.length; i++) {
this.bindTree(mutation.removedNodes[i], true); this.bindTree(mutation.removedNodes[i], true)
} }
for (let i = 0; i < mutation.addedNodes.length; i++) { for (let i = 0; i < mutation.addedNodes.length; i++) {
this.bindTree(mutation.addedNodes[i]); this.bindTree(mutation.addedNodes[i])
} }
continue; continue
} }
const id = this.app.nodes.getID(target); const id = this.app.nodes.getID(target)
if (id === undefined) { if (id === undefined) {
continue; continue
} }
if (!this.recents.has(id)) { if (!this.recents.has(id)) {
this.recents.set(id, RecentsType.Changed); // TODO only when altered this.recents.set(id, RecentsType.Changed) // TODO only when altered
} }
if (type === 'attributes') { if (type === 'attributes') {
const name = mutation.attributeName; const name = mutation.attributeName
if (name === null) { if (name === null) {
continue; continue
} }
let attr = this.attributesMap.get(id); let attr = this.attributesMap.get(id)
if (attr === undefined) { if (attr === undefined) {
this.attributesMap.set(id, (attr = new Set())); this.attributesMap.set(id, (attr = new Set()))
} }
attr.add(name); attr.add(name)
continue; continue
} }
if (type === 'characterData') { if (type === 'characterData') {
this.textSet.add(id); this.textSet.add(id)
continue; continue
} }
} }
this.commitNodes(); this.commitNodes()
}), }),
); )
} }
private clear(): void { private clear(): void {
this.commited.length = 0; this.commited.length = 0
this.recents.clear(); this.recents.clear()
this.indexes.length = 1; this.indexes.length = 1
this.attributesMap.clear(); this.attributesMap.clear()
this.textSet.clear(); this.textSet.clear()
} }
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void { private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
if (isSVGElement(node)) { if (isSVGElement(node)) {
if (name.substr(0, 6) === 'xlink:') { if (name.substr(0, 6) === 'xlink:') {
name = name.substr(6); name = name.substr(6)
} }
if (value === null) { if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name)); this.app.send(RemoveNodeAttribute(id, name))
} else if (name === 'href') { } else if (name === 'href') {
if (value.length > 1e5) { if (value.length > 1e5) {
value = ''; value = ''
} }
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref())); this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
} else { } else {
this.app.send(new SetNodeAttribute(id, name, value)); this.app.send(SetNodeAttribute(id, name, value))
} }
return; return
} }
if ( if (
name === 'src' || name === 'src' ||
@ -144,7 +144,7 @@ export default abstract class Observer {
name === 'autocomplete' || name === 'autocomplete' ||
name.substr(0, 2) === 'on' name.substr(0, 2) === 'on'
) { ) {
return; return
} }
if ( if (
name === 'value' && name === 'value' &&
@ -153,50 +153,50 @@ export default abstract class Observer {
node.type !== 'reset' && node.type !== 'reset' &&
node.type !== 'submit' node.type !== 'submit'
) { ) {
return; return
} }
if (value === null) { if (value === null) {
this.app.send(new RemoveNodeAttribute(id, name)); this.app.send(RemoveNodeAttribute(id, name))
return; return
} }
if (name === 'style' || (name === 'href' && hasTag(node, 'LINK'))) { if (name === 'style' || (name === 'href' && hasTag(node, 'LINK'))) {
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref())); this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
return; return
} }
if (name === 'href' || value.length > 1e5) { if (name === 'href' || value.length > 1e5) {
value = ''; value = ''
} }
this.app.send(new SetNodeAttribute(id, name, value)); this.app.send(SetNodeAttribute(id, name, value))
} }
private sendNodeData(id: number, parentElement: Element, data: string): void { private sendNodeData(id: number, parentElement: Element, data: string): void {
if (hasTag(parentElement, 'STYLE') || hasTag(parentElement, 'style')) { if (hasTag(parentElement, 'STYLE') || hasTag(parentElement, 'style')) {
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref())); this.app.send(SetCSSDataURLBased(id, data, this.app.getBaseHref()))
return; return
} }
data = this.app.sanitizer.sanitize(id, data); data = this.app.sanitizer.sanitize(id, data)
this.app.send(new SetNodeData(id, data)); this.app.send(SetNodeData(id, data))
} }
private bindNode(node: Node): void { private bindNode(node: Node): void {
const [id, isNew] = this.app.nodes.registerNode(node); const [id, isNew] = this.app.nodes.registerNode(node)
if (isNew) { if (isNew) {
this.recents.set(id, RecentsType.New); this.recents.set(id, RecentsType.New)
} else if (this.recents.get(id) !== RecentsType.New) { } else if (this.recents.get(id) !== RecentsType.New) {
// can we do just `else` here? // can we do just `else` here?
this.recents.set(id, RecentsType.Removed); this.recents.set(id, RecentsType.Removed)
} }
} }
private unbindChildNode(node: Node): void { private unbindChildNode(node: Node): void {
const [id] = this.app.nodes.registerNode(node); const [id] = this.app.nodes.registerNode(node)
this.recents.set(id, RecentsType.RemovedChild); this.recents.set(id, RecentsType.RemovedChild)
} }
private bindTree(node: Node, isChildUnbinding = false): void { private bindTree(node: Node, isChildUnbinding = false): void {
if (!isObservable(node)) { if (!isObservable(node)) {
return; return
} }
this.bindNode(node); this.bindNode(node)
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
node, node,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
@ -208,30 +208,30 @@ export default abstract class Observer {
}, },
// @ts-ignore // @ts-ignore
false, false,
); )
while (walker.nextNode()) { while (walker.nextNode()) {
if (isChildUnbinding) { if (isChildUnbinding) {
this.unbindChildNode(walker.currentNode); this.unbindChildNode(walker.currentNode)
} else { } else {
this.bindNode(walker.currentNode); this.bindNode(walker.currentNode)
} }
} }
} }
private unbindNode(node: Node) { private unbindNode(node: Node) {
const id = this.app.nodes.unregisterNode(node); const id = this.app.nodes.unregisterNode(node)
if (id !== undefined && this.recents.get(id) === RecentsType.Removed) { if (id !== undefined && this.recents.get(id) === RecentsType.Removed) {
this.app.send(new RemoveNode(id)); this.app.send(RemoveNode(id))
} }
} }
// A top-consumption function on the infinite lists test. (~1% of performance resources) // A top-consumption function on the infinite lists test. (~1% of performance resources)
private _commitNode(id: number, node: Node): boolean { private _commitNode(id: number, node: Node): boolean {
if (isRootNode(node)) { if (isRootNode(node)) {
return true; return true
} }
const parent = node.parentNode; const parent = node.parentNode
let parentID: number | undefined; let parentID: number | undefined
// Disable parent check for the upper context HTMLHtmlElement, because it is root there... (before) // Disable parent check for the upper context HTMLHtmlElement, because it is root there... (before)
// TODO: get rid of "special" cases (there is an issue with CreateDocument altered behaviour though) // TODO: get rid of "special" cases (there is an issue with CreateDocument altered behaviour though)
@ -240,109 +240,109 @@ export default abstract class Observer {
if (parent === null) { if (parent === null) {
// Sometimes one observation contains attribute mutations for the removimg node, which gets ignored here. // Sometimes one observation contains attribute mutations for the removimg node, which gets ignored here.
// That shouldn't affect the visual rendering ( should it? ) // That shouldn't affect the visual rendering ( should it? )
this.unbindNode(node); this.unbindNode(node)
return false; return false
} }
parentID = this.app.nodes.getID(parent); parentID = this.app.nodes.getID(parent)
if (parentID === undefined) { if (parentID === undefined) {
this.unbindNode(node); this.unbindNode(node)
return false; return false
} }
if (!this.commitNode(parentID)) { if (!this.commitNode(parentID)) {
this.unbindNode(node); this.unbindNode(node)
return false; return false
} }
this.app.sanitizer.handleNode(id, parentID, node); this.app.sanitizer.handleNode(id, parentID, node)
if (this.app.sanitizer.isMaskedContainer(parentID)) { if (this.app.sanitizer.isMaskedContainer(parentID)) {
return false; return false
} }
} }
// From here parentID === undefined if node is top context HTML node // From here parentID === undefined if node is top context HTML node
let sibling = node.previousSibling; let sibling = node.previousSibling
while (sibling !== null) { while (sibling !== null) {
const siblingID = this.app.nodes.getID(sibling); const siblingID = this.app.nodes.getID(sibling)
if (siblingID !== undefined) { if (siblingID !== undefined) {
this.commitNode(siblingID); this.commitNode(siblingID)
this.indexes[id] = this.indexes[siblingID] + 1; this.indexes[id] = this.indexes[siblingID] + 1
break; break
} }
sibling = sibling.previousSibling; sibling = sibling.previousSibling
} }
if (sibling === null) { if (sibling === null) {
this.indexes[id] = 0; this.indexes[id] = 0
} }
const recentsType = this.recents.get(id); const recentsType = this.recents.get(id)
const isNew = recentsType === RecentsType.New; const isNew = recentsType === RecentsType.New
const index = this.indexes[id]; const index = this.indexes[id]
if (index === undefined) { if (index === undefined) {
throw 'commitNode: missing node index'; throw 'commitNode: missing node index'
} }
if (isNew) { if (isNew) {
if (isElementNode(node)) { if (isElementNode(node)) {
let el: Element = node; let el: Element = node
if (parentID !== undefined) { if (parentID !== undefined) {
if (this.app.sanitizer.isMaskedContainer(id)) { if (this.app.sanitizer.isMaskedContainer(id)) {
const width = el.clientWidth; const width = el.clientWidth
const height = el.clientHeight; const height = el.clientHeight
el = node.cloneNode() as Element; el = node.cloneNode() as Element
(el as HTMLElement | SVGElement).style.width = width + 'px'; ;(el as HTMLElement | SVGElement).style.width = width + 'px'
(el as HTMLElement | SVGElement).style.height = height + 'px'; ;(el as HTMLElement | SVGElement).style.height = height + 'px'
} }
this.app.send(new CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node))); this.app.send(CreateElementNode(id, parentID, index, el.tagName, isSVGElement(node)))
} }
for (let i = 0; i < el.attributes.length; i++) { for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i]; const attr = el.attributes[i]
this.sendNodeAttribute(id, el, attr.nodeName, attr.value); this.sendNodeAttribute(id, el, attr.nodeName, attr.value)
} }
} else if (isTextNode(node)) { } else if (isTextNode(node)) {
// for text node id != 0, hence parentID !== undefined and parent is Element // for text node id != 0, hence parentID !== undefined and parent is Element
this.app.send(new CreateTextNode(id, parentID as number, index)); this.app.send(CreateTextNode(id, parentID as number, index))
this.sendNodeData(id, parent as Element, node.data); this.sendNodeData(id, parent as Element, node.data)
} }
return true; return true
} }
if (recentsType === RecentsType.Removed && parentID !== undefined) { if (recentsType === RecentsType.Removed && parentID !== undefined) {
this.app.send(new MoveNode(id, parentID, index)); this.app.send(MoveNode(id, parentID, index))
} }
const attr = this.attributesMap.get(id); const attr = this.attributesMap.get(id)
if (attr !== undefined) { if (attr !== undefined) {
if (!isElementNode(node)) { if (!isElementNode(node)) {
throw 'commitNode: node is not an element'; throw 'commitNode: node is not an element'
} }
for (const name of attr) { for (const name of attr) {
this.sendNodeAttribute(id, node, name, node.getAttribute(name)); this.sendNodeAttribute(id, node, name, node.getAttribute(name))
} }
} }
if (this.textSet.has(id)) { if (this.textSet.has(id)) {
if (!isTextNode(node)) { if (!isTextNode(node)) {
throw 'commitNode: node is not a text'; throw 'commitNode: node is not a text'
} }
// for text node id != 0, hence parent is Element // for text node id != 0, hence parent is Element
this.sendNodeData(id, parent as Element, node.data); this.sendNodeData(id, parent as Element, node.data)
} }
return true; return true
} }
private commitNode(id: number): boolean { private commitNode(id: number): boolean {
const node = this.app.nodes.getNode(id); const node = this.app.nodes.getNode(id)
if (node === undefined) { if (node === undefined) {
return false; return false
} }
const cmt = this.commited[id]; const cmt = this.commited[id]
if (cmt !== undefined) { if (cmt !== undefined) {
return cmt; return cmt
} }
return (this.commited[id] = this._commitNode(id, node)); return (this.commited[id] = this._commitNode(id, node))
} }
private commitNodes(isStart = false): void { private commitNodes(isStart = false): void {
let node; let node
this.recents.forEach((type, id) => { this.recents.forEach((type, id) => {
this.commitNode(id); this.commitNode(id)
if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) { if (type === RecentsType.New && (node = this.app.nodes.getNode(id))) {
this.app.nodes.callNodeCallbacks(node, isStart); this.app.nodes.callNodeCallbacks(node, isStart)
} }
}); })
this.clear(); this.clear()
} }
// ISSSUE // ISSSUE
@ -358,14 +358,14 @@ export default abstract class Observer {
subtree: true, subtree: true,
attributeOldValue: false, attributeOldValue: false,
characterDataOldValue: false, characterDataOldValue: false,
}); })
this.bindTree(nodeToBind); this.bindTree(nodeToBind)
beforeCommit(this.app.nodes.getID(node)); beforeCommit(this.app.nodes.getID(node))
this.commitNodes(true); this.commitNodes(true)
} }
disconnect(): void { disconnect(): void {
this.observer.disconnect(); this.observer.disconnect()
this.clear(); this.clear()
} }
} }

View file

@ -1,19 +1,19 @@
import Observer from './observer.js'; import Observer from './observer.js'
import { CreateIFrameDocument } from '../../../common/messages.js'; import { CreateIFrameDocument } from '../messages.gen.js'
export default class ShadowRootObserver extends Observer { export default class ShadowRootObserver extends Observer {
observe(el: Element) { observe(el: Element) {
const shRoot = el.shadowRoot; const shRoot = el.shadowRoot
const hostID = this.app.nodes.getID(el); const hostID = this.app.nodes.getID(el)
if (!shRoot || hostID === undefined) { if (!shRoot || hostID === undefined) {
return; return
} // log } // log
this.observeRoot(shRoot, (rootID) => { this.observeRoot(shRoot, (rootID) => {
if (rootID === undefined) { if (rootID === undefined) {
console.log('OpenReplay: Shadow Root was not bound'); console.log('OpenReplay: Shadow Root was not bound')
return; return
} }
this.app.send(CreateIFrameDocument(hostID, rootID)); this.app.send(CreateIFrameDocument(hostID, rootID))
}); })
} }
} }

View file

@ -1,29 +1,29 @@
import Observer from './observer.js'; import Observer from './observer.js'
import { isElementNode, hasTag } from '../guards.js'; import { isElementNode, hasTag } from '../guards.js'
import IFrameObserver from './iframe_observer.js'; import IFrameObserver from './iframe_observer.js'
import ShadowRootObserver from './shadow_root_observer.js'; import ShadowRootObserver from './shadow_root_observer.js'
import { CreateDocument } from '../../../common/messages.js'; import { CreateDocument } from '../messages.gen.js'
import App from '../index.js'; import App from '../index.js'
import { IN_BROWSER, hasOpenreplayAttribute } from '../../utils.js'; import { IN_BROWSER, hasOpenreplayAttribute } from '../../utils.js'
export interface Options { export interface Options {
captureIFrames: boolean; captureIFrames: boolean
} }
const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () => new ShadowRoot(); const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () => new ShadowRoot()
export default class TopObserver extends Observer { export default class TopObserver extends Observer {
private readonly options: Options; private readonly options: Options
constructor(app: App, options: Partial<Options>) { constructor(app: App, options: Partial<Options>) {
super(app, true); super(app, true)
this.options = Object.assign( this.options = Object.assign(
{ {
captureIFrames: true, captureIFrames: true,
}, },
options, options,
); )
// IFrames // IFrames
this.app.nodes.attachNodeCallback((node) => { this.app.nodes.attachNodeCallback((node) => {
@ -32,59 +32,59 @@ export default class TopObserver extends Observer {
((this.options.captureIFrames && !hasOpenreplayAttribute(node, 'obscured')) || ((this.options.captureIFrames && !hasOpenreplayAttribute(node, 'obscured')) ||
hasOpenreplayAttribute(node, 'capture')) hasOpenreplayAttribute(node, 'capture'))
) { ) {
this.handleIframe(node); this.handleIframe(node)
} }
}); })
// ShadowDOM // ShadowDOM
this.app.nodes.attachNodeCallback((node) => { this.app.nodes.attachNodeCallback((node) => {
if (isElementNode(node) && node.shadowRoot !== null) { if (isElementNode(node) && node.shadowRoot !== null) {
this.handleShadowRoot(node.shadowRoot); this.handleShadowRoot(node.shadowRoot)
} }
}); })
} }
private iframeObservers: IFrameObserver[] = []; private iframeObservers: IFrameObserver[] = []
private handleIframe(iframe: HTMLIFrameElement): void { private handleIframe(iframe: HTMLIFrameElement): void {
let doc: Document | null = null; let doc: Document | null = null
const handle = this.app.safe(() => { const handle = this.app.safe(() => {
const id = this.app.nodes.getID(iframe); const id = this.app.nodes.getID(iframe)
if (id === undefined) { if (id === undefined) {
return; return
} //log } //log
if (iframe.contentDocument === doc) { if (iframe.contentDocument === doc) {
return; return
} // How frequently can it happen? } // How frequently can it happen?
doc = iframe.contentDocument; doc = iframe.contentDocument
if (!doc || !iframe.contentWindow) { if (!doc || !iframe.contentWindow) {
return; return
} }
const observer = new IFrameObserver(this.app); const observer = new IFrameObserver(this.app)
this.iframeObservers.push(observer); this.iframeObservers.push(observer)
observer.observe(iframe); observer.observe(iframe)
}); })
iframe.addEventListener('load', handle); // why app.attachEventListener not working? iframe.addEventListener('load', handle) // why app.attachEventListener not working?
handle(); handle()
} }
private shadowRootObservers: ShadowRootObserver[] = []; private shadowRootObservers: ShadowRootObserver[] = []
private handleShadowRoot(shRoot: ShadowRoot) { private handleShadowRoot(shRoot: ShadowRoot) {
const observer = new ShadowRootObserver(this.app); const observer = new ShadowRootObserver(this.app)
this.shadowRootObservers.push(observer); this.shadowRootObservers.push(observer)
observer.observe(shRoot.host); observer.observe(shRoot.host)
} }
observe(): void { observe(): void {
// Protection from several subsequent calls? // Protection from several subsequent calls?
const observer = this; const observer = this
Element.prototype.attachShadow = function () { Element.prototype.attachShadow = function () {
// eslint-disable-next-line // eslint-disable-next-line
const shadow = attachShadowNativeFn.apply(this, arguments); const shadow = attachShadowNativeFn.apply(this, arguments)
observer.handleShadowRoot(shadow); observer.handleShadowRoot(shadow)
return shadow; return shadow
}; }
// Can observe documentElement (<html>) here, because it is not supposed to be changing. // Can observe documentElement (<html>) here, because it is not supposed to be changing.
// However, it is possible in some exotic cases and may cause an ignorance of the newly created <html> // However, it is possible in some exotic cases and may cause an ignorance of the newly created <html>
@ -95,18 +95,18 @@ export default class TopObserver extends Observer {
this.observeRoot( this.observeRoot(
window.document, window.document,
() => { () => {
this.app.send(new CreateDocument()); this.app.send(CreateDocument())
}, },
window.document.documentElement, window.document.documentElement,
); )
} }
disconnect() { disconnect() {
Element.prototype.attachShadow = attachShadowNativeFn; Element.prototype.attachShadow = attachShadowNativeFn
this.iframeObservers.forEach((o) => o.disconnect()); this.iframeObservers.forEach((o) => o.disconnect())
this.iframeObservers = []; this.iframeObservers = []
this.shadowRootObservers.forEach((o) => o.disconnect()); this.shadowRootObservers.forEach((o) => o.disconnect())
this.shadowRootObservers = []; this.shadowRootObservers = []
super.disconnect(); super.disconnect()
} }
} }

View file

@ -1,16 +1,16 @@
import type App from './index.js'; import type App from './index.js'
import { stars, hasOpenreplayAttribute } from '../utils.js'; import { stars, hasOpenreplayAttribute } from '../utils.js'
import { isElementNode } from './guards.js'; import { isElementNode } from './guards.js'
export interface Options { export interface Options {
obscureTextEmails: boolean; obscureTextEmails: boolean
obscureTextNumbers: boolean; obscureTextNumbers: boolean
} }
export default class Sanitizer { export default class Sanitizer {
private readonly masked: Set<number> = new Set(); private readonly masked: Set<number> = new Set()
private readonly maskedContainers: Set<number> = new Set(); private readonly maskedContainers: Set<number> = new Set()
private readonly options: Options; private readonly options: Options
constructor(private readonly app: App, options: Partial<Options>) { constructor(private readonly app: App, options: Partial<Options>) {
this.options = Object.assign( this.options = Object.assign(
@ -19,7 +19,7 @@ export default class Sanitizer {
obscureTextNumbers: false, obscureTextNumbers: false,
}, },
options, options,
); )
} }
handleNode(id: number, parentID: number, node: Node) { handleNode(id: number, parentID: number, node: Node) {
@ -27,13 +27,13 @@ export default class Sanitizer {
this.masked.has(parentID) || this.masked.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'masked')) (isElementNode(node) && hasOpenreplayAttribute(node, 'masked'))
) { ) {
this.masked.add(id); this.masked.add(id)
} }
if ( if (
this.maskedContainers.has(parentID) || this.maskedContainers.has(parentID) ||
(isElementNode(node) && hasOpenreplayAttribute(node, 'htmlmasked')) (isElementNode(node) && hasOpenreplayAttribute(node, 'htmlmasked'))
) { ) {
this.maskedContainers.add(id); this.maskedContainers.add(id)
} }
} }
@ -42,40 +42,37 @@ export default class Sanitizer {
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases? // TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
return data return data
.trim() .trim()
.replace( .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█')
/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g,
'█',
);
} }
if (this.options.obscureTextNumbers) { if (this.options.obscureTextNumbers) {
data = data.replace(/\d/g, '0'); data = data.replace(/\d/g, '0')
} }
if (this.options.obscureTextEmails) { if (this.options.obscureTextEmails) {
data = data.replace( data = data.replace(
/([^\s]+)@([^\s]+)\.([^\s]+)/g, /([^\s]+)@([^\s]+)\.([^\s]+)/g,
(...f: Array<string>) => stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]), (...f: Array<string>) => stars(f[1]) + '@' + stars(f[2]) + '.' + stars(f[3]),
); )
} }
return data; return data
} }
isMasked(id: number): boolean { isMasked(id: number): boolean {
return this.masked.has(id); return this.masked.has(id)
} }
isMaskedContainer(id: number) { isMaskedContainer(id: number) {
return this.maskedContainers.has(id); return this.maskedContainers.has(id)
} }
getInnerTextSecure(el: HTMLElement): string { getInnerTextSecure(el: HTMLElement): string {
const id = this.app.nodes.getID(el); const id = this.app.nodes.getID(el)
if (!id) { if (!id) {
return ''; return ''
} }
return this.sanitize(id, el.innerText); return this.sanitize(id, el.innerText)
} }
clear(): void { clear(): void {
this.masked.clear(); this.masked.clear()
this.maskedContainers.clear(); this.maskedContainers.clear()
} }
} }

View file

@ -1,52 +1,50 @@
import { UserID, UserAnonymousID, Metadata } from '../../common/messages.js';
interface SessionInfo { interface SessionInfo {
sessionID: string | null; sessionID: string | null
metadata: Record<string, string>; metadata: Record<string, string>
userID: string | null; userID: string | null
} }
type OnUpdateCallback = (i: Partial<SessionInfo>) => void; type OnUpdateCallback = (i: Partial<SessionInfo>) => void
export default class Session { export default class Session {
private metadata: Record<string, string> = {}; private metadata: Record<string, string> = {}
private userID: string | null = null; private userID: string | null = null
private sessionID: string | null = null; private sessionID: string | null = null
private readonly callbacks: OnUpdateCallback[] = []; private readonly callbacks: OnUpdateCallback[] = []
attachUpdateCallback(cb: OnUpdateCallback) { attachUpdateCallback(cb: OnUpdateCallback) {
this.callbacks.push(cb); this.callbacks.push(cb)
} }
private handleUpdate(newInfo: Partial<SessionInfo>) { private handleUpdate(newInfo: Partial<SessionInfo>) {
if (newInfo.userID == null) { if (newInfo.userID == null) {
delete newInfo.userID; delete newInfo.userID
} }
if (newInfo.sessionID == null) { if (newInfo.sessionID == null) {
delete newInfo.sessionID; delete newInfo.sessionID
} }
this.callbacks.forEach((cb) => cb(newInfo)); this.callbacks.forEach((cb) => cb(newInfo))
} }
update(newInfo: Partial<SessionInfo>) { update(newInfo: Partial<SessionInfo>) {
if (newInfo.userID !== undefined) { if (newInfo.userID !== undefined) {
// TODO clear nullable/undefinable types // TODO clear nullable/undefinable types
this.userID = newInfo.userID; this.userID = newInfo.userID
} }
if (newInfo.metadata !== undefined) { if (newInfo.metadata !== undefined) {
Object.entries(newInfo.metadata).forEach(([k, v]) => (this.metadata[k] = v)); Object.entries(newInfo.metadata).forEach(([k, v]) => (this.metadata[k] = v))
} }
if (newInfo.sessionID !== undefined) { if (newInfo.sessionID !== undefined) {
this.sessionID = newInfo.sessionID; this.sessionID = newInfo.sessionID
} }
this.handleUpdate(newInfo); this.handleUpdate(newInfo)
} }
setMetadata(key: string, value: string) { setMetadata(key: string, value: string) {
this.metadata[key] = value; this.metadata[key] = value
this.handleUpdate({ metadata: { [key]: value } }); this.handleUpdate({ metadata: { [key]: value } })
} }
setUserID(userID: string) { setUserID(userID: string) {
this.userID = userID; this.userID = userID
this.handleUpdate({ userID }); this.handleUpdate({ userID })
} }
getInfo(): SessionInfo { getInfo(): SessionInfo {
@ -54,12 +52,12 @@ export default class Session {
sessionID: this.sessionID, sessionID: this.sessionID,
metadata: this.metadata, metadata: this.metadata,
userID: this.userID, userID: this.userID,
}; }
} }
reset(): void { reset(): void {
this.metadata = {}; this.metadata = {}
this.userID = null; this.userID = null
this.sessionID = null; this.sessionID = null
} }
} }

View file

@ -1,31 +1,31 @@
import App from './index.js'; import App from './index.js'
type Callback = () => void; type Callback = () => void
function wrap(callback: Callback, n: number): Callback { function wrap(callback: Callback, n: number): Callback {
let t = 0; let t = 0
return (): void => { return (): void => {
if (t++ >= n) { if (t++ >= n) {
t = 0; t = 0
callback(); callback()
} }
}; }
} }
export default class Ticker { export default class Ticker {
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null
private readonly callbacks: Array<Callback | undefined>; private readonly callbacks: Array<Callback | undefined>
constructor(private readonly app: App) { constructor(private readonly app: App) {
this.callbacks = []; this.callbacks = []
} }
attach(callback: Callback, n = 0, useSafe = true, thisArg?: any) { attach(callback: Callback, n = 0, useSafe = true, thisArg?: any) {
if (thisArg) { if (thisArg) {
callback = callback.bind(thisArg); callback = callback.bind(thisArg)
} }
if (useSafe) { if (useSafe) {
callback = this.app.safe(callback); callback = this.app.safe(callback)
} }
this.callbacks.unshift(n ? wrap(callback, n) : callback) - 1; this.callbacks.unshift(n ? wrap(callback, n) : callback) - 1
} }
start(): void { start(): void {
@ -33,17 +33,17 @@ export default class Ticker {
this.timer = setInterval( this.timer = setInterval(
() => () =>
this.callbacks.forEach((cb) => { this.callbacks.forEach((cb) => {
if (cb) cb(); if (cb) cb()
}), }),
30, 30,
); )
} }
} }
stop(): void { stop(): void {
if (this.timer !== null) { if (this.timer !== null) {
clearInterval(this.timer); clearInterval(this.timer)
this.timer = null; this.timer = null
} }
} }
} }

View file

@ -1,62 +1,56 @@
import App, { DEFAULT_INGEST_POINT } from './app/index.js'; import App, { DEFAULT_INGEST_POINT } from './app/index.js'
export { default as App } from './app/index.js'; export { default as App } from './app/index.js'
import { import { UserID, UserAnonymousID, RawCustomEvent, CustomIssue } from './app/messages.gen.js'
UserID, import * as _Messages from './app/messages.gen.js'
UserAnonymousID, export const Messages = _Messages
Metadata,
RawCustomEvent,
CustomIssue,
} from '../common/messages.js';
import * as _Messages from '../common/messages.js';
export const Messages = _Messages;
import Connection from './modules/connection.js'; import Connection from './modules/connection.js'
import Console from './modules/console.js'; import Console from './modules/console.js'
import Exception, { import Exception, {
getExceptionMessageFromEvent, getExceptionMessageFromEvent,
getExceptionMessage, getExceptionMessage,
} from './modules/exception.js'; } from './modules/exception.js'
import Img from './modules/img.js'; import Img from './modules/img.js'
import Input from './modules/input.js'; import Input from './modules/input.js'
import Mouse from './modules/mouse.js'; import Mouse from './modules/mouse.js'
import Timing from './modules/timing.js'; import Timing from './modules/timing.js'
import Performance from './modules/performance.js'; import Performance from './modules/performance.js'
import Scroll from './modules/scroll.js'; import Scroll from './modules/scroll.js'
import Viewport from './modules/viewport.js'; import Viewport from './modules/viewport.js'
import CSSRules from './modules/cssrules.js'; import CSSRules from './modules/cssrules.js'
import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'; import { IN_BROWSER, deprecationWarn, DOCS_HOST } from './utils.js'
import type { Options as AppOptions } from './app/index.js'; import type { Options as AppOptions } from './app/index.js'
import type { Options as ConsoleOptions } from './modules/console.js'; import type { Options as ConsoleOptions } from './modules/console.js'
import type { Options as ExceptionOptions } from './modules/exception.js'; import type { Options as ExceptionOptions } from './modules/exception.js'
import type { Options as InputOptions } from './modules/input.js'; import type { Options as InputOptions } from './modules/input.js'
import type { Options as PerformanceOptions } from './modules/performance.js'; import type { Options as PerformanceOptions } from './modules/performance.js'
import type { Options as TimingOptions } from './modules/timing.js'; import type { Options as TimingOptions } from './modules/timing.js'
import type { StartOptions } from './app/index.js'; import type { StartOptions } from './app/index.js'
//TODO: unique options init //TODO: unique options init
import type { StartPromiseReturn } from './app/index.js'; import type { StartPromiseReturn } from './app/index.js'
export type Options = Partial< export type Options = Partial<
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
> & { > & {
projectID?: number; // For the back compatibility only (deprecated) projectID?: number // For the back compatibility only (deprecated)
projectKey: string; projectKey: string
sessionToken?: string; sessionToken?: string
respectDoNotTrack?: boolean; respectDoNotTrack?: boolean
autoResetOnWindowOpen?: boolean; autoResetOnWindowOpen?: boolean
// dev only // dev only
__DISABLE_SECURE_MODE?: boolean; __DISABLE_SECURE_MODE?: boolean
}; }
const DOCS_SETUP = '/installation/setup-or'; const DOCS_SETUP = '/installation/setup-or'
function processOptions(obj: any): obj is Options { function processOptions(obj: any): obj is Options {
if (obj == null) { if (obj == null) {
console.error( console.error(
`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`, `OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
); )
return false; return false
} }
if (typeof obj.projectKey !== 'string') { if (typeof obj.projectKey !== 'string') {
if (typeof obj.projectKey !== 'number') { if (typeof obj.projectKey !== 'number') {
@ -64,46 +58,46 @@ function processOptions(obj: any): obj is Options {
// Back compatability // Back compatability
console.error( console.error(
`OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`, `OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`,
); )
return false; return false
} else { } else {
obj.projectKey = obj.projectID.toString(); obj.projectKey = obj.projectID.toString()
deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP); deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP)
} }
} else { } else {
console.warn('OpenReplay: projectKey is expected to have a string type.'); console.warn('OpenReplay: projectKey is expected to have a string type.')
obj.projectKey = obj.projectKey.toString(); obj.projectKey = obj.projectKey.toString()
} }
} }
if (typeof obj.sessionToken !== 'string' && obj.sessionToken != null) { if (typeof obj.sessionToken !== 'string' && obj.sessionToken != null) {
console.warn( console.warn(
`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`, `OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
); )
} }
return true; return true
} }
export default class API { export default class API {
private readonly app: App | null = null; private readonly app: App | null = null
constructor(private readonly options: Options) { constructor(private readonly options: Options) {
if (!IN_BROWSER || !processOptions(options)) { if (!IN_BROWSER || !processOptions(options)) {
return; return
} }
if ((window as any).__OPENREPLAY__) { if ((window as any).__OPENREPLAY__) {
console.error('OpenReplay: one tracker instance has been initialised already'); console.error('OpenReplay: one tracker instance has been initialised already')
return; return
} }
if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') { if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') {
console.error( console.error(
'OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.', 'OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.',
); )
return; return
} }
const doNotTrack = const doNotTrack =
options.respectDoNotTrack && options.respectDoNotTrack &&
(navigator.doNotTrack == '1' || (navigator.doNotTrack == '1' ||
// @ts-ignore // @ts-ignore
window.doNotTrack == '1'); window.doNotTrack == '1')
const app = (this.app = const app = (this.app =
doNotTrack || doNotTrack ||
!('Map' in window) || !('Map' in window) ||
@ -115,42 +109,42 @@ export default class API {
!('Blob' in window) || !('Blob' in window) ||
!('Worker' in window) !('Worker' in window)
? null ? null
: new App(options.projectKey, options.sessionToken, options)); : new App(options.projectKey, options.sessionToken, options))
if (app !== null) { if (app !== null) {
Viewport(app); Viewport(app)
CSSRules(app); CSSRules(app)
Connection(app); Connection(app)
Console(app, options); Console(app, options)
Exception(app, options); Exception(app, options)
Img(app); Img(app)
Input(app, options); Input(app, options)
Mouse(app); Mouse(app)
Timing(app, options); Timing(app, options)
Performance(app, options); Performance(app, options)
Scroll(app); Scroll(app)
(window as any).__OPENREPLAY__ = this; ;(window as any).__OPENREPLAY__ = this
if (options.autoResetOnWindowOpen) { if (options.autoResetOnWindowOpen) {
const wOpen = window.open; const wOpen = window.open
app.attachStartCallback(() => { app.attachStartCallback(() => {
// @ts-ignore ? // @ts-ignore ?
window.open = function (...args) { window.open = function (...args) {
app.resetNextPageSession(true); app.resetNextPageSession(true)
wOpen.call(window, ...args); wOpen.call(window, ...args)
app.resetNextPageSession(false); app.resetNextPageSession(false)
}; }
}); })
app.attachStopCallback(() => { app.attachStopCallback(() => {
window.open = wOpen; window.open = wOpen
}); })
} }
} else { } else {
console.log( console.log(
"OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.", "OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.",
); )
const req = new XMLHttpRequest(); const req = new XMLHttpRequest()
const orig = options.ingestPoint || DEFAULT_INGEST_POINT; const orig = options.ingestPoint || DEFAULT_INGEST_POINT
req.open('POST', orig + '/v1/web/not-started'); req.open('POST', orig + '/v1/web/not-started')
// no-cors issue only with text/plain or not-set Content-Type // no-cors issue only with text/plain or not-set Content-Type
// req.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); // req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
req.send( req.send(
@ -160,99 +154,99 @@ export default class API {
doNotTrack, doNotTrack,
// TODO: add precise reason (an exact API missing) // TODO: add precise reason (an exact API missing)
}), }),
); )
} }
} }
use<T>(fn: (app: App | null, options?: Options) => T): T { use<T>(fn: (app: App | null, options?: Options) => T): T {
return fn(this.app, this.options); return fn(this.app, this.options)
} }
isActive(): boolean { isActive(): boolean {
if (this.app === null) { if (this.app === null) {
return false; return false
} }
return this.app.active(); return this.app.active()
} }
start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> { start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
if (!IN_BROWSER) { if (!IN_BROWSER) {
console.error( console.error(
`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`, `OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
); )
return Promise.reject('Trying to start not in browser.'); return Promise.reject('Trying to start not in browser.')
} }
if (this.app === null) { if (this.app === null) {
return Promise.reject("Browser doesn't support required api, or doNotTrack is active."); return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
} }
// TODO: check argument type // TODO: check argument type
return this.app.start(startOpts); return this.app.start(startOpts)
} }
stop(): void { stop(): void {
if (this.app === null) { if (this.app === null) {
return; return
} }
this.app.stop(true); this.app.stop(true)
} }
getSessionToken(): string | null | undefined { getSessionToken(): string | null | undefined {
if (this.app === null) { if (this.app === null) {
return null; return null
} }
return this.app.getSessionToken(); return this.app.getSessionToken()
} }
getSessionID(): string | null | undefined { getSessionID(): string | null | undefined {
if (this.app === null) { if (this.app === null) {
return null; return null
} }
return this.app.getSessionID(); return this.app.getSessionID()
} }
sessionID(): string | null | undefined { sessionID(): string | null | undefined {
deprecationWarn("'sessionID' method", "'getSessionID' method", '/'); deprecationWarn("'sessionID' method", "'getSessionID' method", '/')
return this.getSessionID(); return this.getSessionID()
} }
setUserID(id: string): void { setUserID(id: string): void {
if (typeof id === 'string' && this.app !== null) { if (typeof id === 'string' && this.app !== null) {
this.app.session.setUserID(id); this.app.session.setUserID(id)
} }
} }
userID(id: string): void { userID(id: string): void {
deprecationWarn("'userID' method", "'setUserID' method", '/'); deprecationWarn("'userID' method", "'setUserID' method", '/')
this.setUserID(id); this.setUserID(id)
} }
setUserAnonymousID(id: string): void { setUserAnonymousID(id: string): void {
if (typeof id === 'string' && this.app !== null) { if (typeof id === 'string' && this.app !== null) {
this.app.send(new UserAnonymousID(id)); this.app.send(UserAnonymousID(id))
} }
} }
userAnonymousID(id: string): void { userAnonymousID(id: string): void {
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/'); deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/')
this.setUserAnonymousID(id); this.setUserAnonymousID(id)
} }
setMetadata(key: string, value: string): void { setMetadata(key: string, value: string): void {
if (typeof key === 'string' && typeof value === 'string' && this.app !== null) { if (typeof key === 'string' && typeof value === 'string' && this.app !== null) {
this.app.session.setMetadata(key, value); this.app.session.setMetadata(key, value)
} }
} }
metadata(key: string, value: string): void { metadata(key: string, value: string): void {
deprecationWarn("'metadata' method", "'setMetadata' method", '/'); deprecationWarn("'metadata' method", "'setMetadata' method", '/')
this.setMetadata(key, value); this.setMetadata(key, value)
} }
event(key: string, payload: any, issue = false): void { event(key: string, payload: any, issue = false): void {
if (typeof key === 'string' && this.app !== null) { if (typeof key === 'string' && this.app !== null) {
if (issue) { if (issue) {
return this.issue(key, payload); return this.issue(key, payload)
} else { } else {
try { try {
payload = JSON.stringify(payload); payload = JSON.stringify(payload)
} catch (e) { } catch (e) {
return; return
} }
this.app.send(new RawCustomEvent(key, payload)); this.app.send(RawCustomEvent(key, payload))
} }
} }
} }
@ -260,28 +254,28 @@ export default class API {
issue(key: string, payload: any): void { issue(key: string, payload: any): void {
if (typeof key === 'string' && this.app !== null) { if (typeof key === 'string' && this.app !== null) {
try { try {
payload = JSON.stringify(payload); payload = JSON.stringify(payload)
} catch (e) { } catch (e) {
return; return
} }
this.app.send(new CustomIssue(key, payload)); this.app.send(CustomIssue(key, payload))
} }
} }
handleError = (e: Error | ErrorEvent | PromiseRejectionEvent) => { handleError = (e: Error | ErrorEvent | PromiseRejectionEvent) => {
if (this.app === null) { if (this.app === null) {
return; return
} }
if (e instanceof Error) { if (e instanceof Error) {
this.app.send(getExceptionMessage(e, [])); this.app.send(getExceptionMessage(e, []))
} else if ( } else if (
e instanceof ErrorEvent || e instanceof ErrorEvent ||
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent) ('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
) { ) {
const msg = getExceptionMessageFromEvent(e); const msg = getExceptionMessageFromEvent(e)
if (msg != null) { if (msg != null) {
this.app.send(msg); this.app.send(msg)
} }
} }
}; }
} }

View file

@ -1,28 +1,25 @@
import App from '../app/index.js'; import App from '../app/index.js'
import { ConnectionInformation } from '../../common/messages.js'; import { ConnectionInformation } from '../app/messages.gen.js'
export default function (app: App): void { export default function (app: App): void {
const connection: const connection:
| { | {
downlink: number; downlink: number
type?: string; type?: string
addEventListener: (type: 'change', cb: () => void) => void; addEventListener: (type: 'change', cb: () => void) => void
} }
| undefined = | undefined =
(navigator as any).connection || (navigator as any).connection ||
(navigator as any).mozConnection || (navigator as any).mozConnection ||
(navigator as any).webkitConnection; (navigator as any).webkitConnection
if (connection === undefined) { if (connection === undefined) {
return; return
} }
const sendConnectionInformation = (): void => const sendConnectionInformation = (): void =>
app.send( app.send(
new ConnectionInformation( ConnectionInformation(Math.round(connection.downlink * 1000), connection.type || 'unknown'),
Math.round(connection.downlink * 1000), )
connection.type || 'unknown', sendConnectionInformation()
), connection.addEventListener('change', sendConnectionInformation)
);
sendConnectionInformation();
connection.addEventListener('change', sendConnectionInformation);
} }

View file

@ -1,100 +1,100 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { hasTag } from '../app/guards.js'; import { hasTag } from '../app/guards.js'
import { IN_BROWSER } from '../utils.js'; import { IN_BROWSER } from '../utils.js'
import { ConsoleLog } from '../../common/messages.js'; import { ConsoleLog } from '../app/messages.gen.js'
const printError: (e: Error) => string = const printError: (e: Error) => string =
IN_BROWSER && 'InstallTrigger' in window // detect Firefox IN_BROWSER && 'InstallTrigger' in window // detect Firefox
? (e: Error): string => e.message + '\n' + e.stack ? (e: Error): string => e.message + '\n' + e.stack
: (e: Error): string => e.stack || e.message; : (e: Error): string => e.stack || e.message
function printString(arg: any): string { function printString(arg: any): string {
if (arg === undefined) { if (arg === undefined) {
return 'undefined'; return 'undefined'
} }
if (arg === null) { if (arg === null) {
return 'null'; return 'null'
} }
if (arg instanceof Error) { if (arg instanceof Error) {
return printError(arg); return printError(arg)
} }
if (Array.isArray(arg)) { if (Array.isArray(arg)) {
return `Array(${arg.length})`; return `Array(${arg.length})`
} }
return String(arg); return String(arg)
} }
function printFloat(arg: any): string { function printFloat(arg: any): string {
if (typeof arg !== 'number') return 'NaN'; if (typeof arg !== 'number') return 'NaN'
return arg.toString(); return arg.toString()
} }
function printInt(arg: any): string { function printInt(arg: any): string {
if (typeof arg !== 'number') return 'NaN'; if (typeof arg !== 'number') return 'NaN'
return Math.floor(arg).toString(); return Math.floor(arg).toString()
} }
function printObject(arg: any): string { function printObject(arg: any): string {
if (arg === undefined) { if (arg === undefined) {
return 'undefined'; return 'undefined'
} }
if (arg === null) { if (arg === null) {
return 'null'; return 'null'
} }
if (arg instanceof Error) { if (arg instanceof Error) {
return printError(arg); return printError(arg)
} }
if (Array.isArray(arg)) { if (Array.isArray(arg)) {
const length = arg.length; const length = arg.length
const values = arg.slice(0, 10).map(printString).join(', '); const values = arg.slice(0, 10).map(printString).join(', ')
return `Array(${length})[${values}]`; return `Array(${length})[${values}]`
} }
if (typeof arg === 'object') { if (typeof arg === 'object') {
const res = []; const res = []
let i = 0; let i = 0
for (const k in arg) { for (const k in arg) {
if (++i === 10) { if (++i === 10) {
break; break
} }
const v = arg[k]; const v = arg[k]
res.push(k + ': ' + printString(v)); res.push(k + ': ' + printString(v))
} }
return '{' + res.join(', ') + '}'; return '{' + res.join(', ') + '}'
} }
return arg.toString(); return arg.toString()
} }
function printf(args: any[]): string { function printf(args: any[]): string {
if (typeof args[0] === 'string') { if (typeof args[0] === 'string') {
args.unshift( args.unshift(
args.shift().replace(/%(o|s|f|d|i)/g, (s: string, t: string): string => { args.shift().replace(/%(o|s|f|d|i)/g, (s: string, t: string): string => {
const arg = args.shift(); const arg = args.shift()
if (arg === undefined) return s; if (arg === undefined) return s
switch (t) { switch (t) {
case 'o': case 'o':
return printObject(arg); return printObject(arg)
case 's': case 's':
return printString(arg); return printString(arg)
case 'f': case 'f':
return printFloat(arg); return printFloat(arg)
case 'd': case 'd':
case 'i': case 'i':
return printInt(arg); return printInt(arg)
default: default:
return s; return s
} }
}), }),
); )
} }
return args.map(printObject).join(' '); return args.map(printObject).join(' ')
} }
export interface Options { export interface Options {
consoleMethods: Array<string> | null; consoleMethods: Array<string> | null
consoleThrottling: number; consoleThrottling: number
} }
const consoleMethods = ['log', 'info', 'warn', 'error', 'debug', 'assert']; const consoleMethods = ['log', 'info', 'warn', 'error', 'debug', 'assert']
export default function (app: App, opts: Partial<Options>): void { export default function (app: App, opts: Partial<Options>): void {
const options: Options = Object.assign( const options: Options = Object.assign(
@ -103,54 +103,54 @@ export default function (app: App, opts: Partial<Options>): void {
consoleThrottling: 30, consoleThrottling: 30,
}, },
opts, opts,
); )
if (!Array.isArray(options.consoleMethods) || options.consoleMethods.length === 0) { if (!Array.isArray(options.consoleMethods) || options.consoleMethods.length === 0) {
return; return
} }
const sendConsoleLog = app.safe((level: string, args: unknown[]): void => const sendConsoleLog = app.safe((level: string, args: unknown[]): void =>
app.send(new ConsoleLog(level, printf(args))), app.send(ConsoleLog(level, printf(args))),
); )
let n: number; let n: number
const reset = (): void => { const reset = (): void => {
n = 0; n = 0
}; }
app.attachStartCallback(reset); app.attachStartCallback(reset)
app.ticker.attach(reset, 33, false); app.ticker.attach(reset, 33, false)
const patchConsole = (console: Console) => const patchConsole = (console: Console) =>
options.consoleMethods!.forEach((method) => { options.consoleMethods!.forEach((method) => {
if (consoleMethods.indexOf(method) === -1) { if (consoleMethods.indexOf(method) === -1) {
console.error(`OpenReplay: unsupported console method "${method}"`); console.error(`OpenReplay: unsupported console method "${method}"`)
return; return
} }
const fn = (console as any)[method]; const fn = (console as any)[method]
(console as any)[method] = function (...args: unknown[]): void { ;(console as any)[method] = function (...args: unknown[]): void {
fn.apply(this, args); fn.apply(this, args)
if (n++ > options.consoleThrottling) { if (n++ > options.consoleThrottling) {
return; return
} }
sendConsoleLog(method, args); sendConsoleLog(method, args)
}; }
}); })
patchConsole(window.console); patchConsole(window.console)
app.nodes.attachNodeCallback( app.nodes.attachNodeCallback(
app.safe((node) => { app.safe((node) => {
if (hasTag(node, 'IFRAME')) { if (hasTag(node, 'IFRAME')) {
// TODO: newContextCallback // TODO: newContextCallback
let context = node.contentWindow; let context = node.contentWindow
if (context) { if (context) {
patchConsole((context as Window & typeof globalThis).console); patchConsole((context as Window & typeof globalThis).console)
} }
app.attachEventListener(node, 'load', () => { app.attachEventListener(node, 'load', () => {
if (node.contentWindow !== context) { if (node.contentWindow !== context) {
context = node.contentWindow; context = node.contentWindow
patchConsole((context as Window & typeof globalThis).console); patchConsole((context as Window & typeof globalThis).console)
} }
}); })
} }
}), }),
); )
} }

View file

@ -1,53 +1,53 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../../common/messages.js'; import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../app/messages.gen.js'
import { hasTag } from '../app/guards.js'; import { hasTag } from '../app/guards.js'
export default function (app: App | null) { export default function (app: App | null) {
if (app === null) { if (app === null) {
return; return
} }
if (!window.CSSStyleSheet) { if (!window.CSSStyleSheet) {
app.send(new TechnicalInfo('no_stylesheet_prototype_in_window', '')); app.send(TechnicalInfo('no_stylesheet_prototype_in_window', ''))
return; return
} }
const processOperation = app.safe((stylesheet: CSSStyleSheet, index: number, rule?: string) => { const processOperation = app.safe((stylesheet: CSSStyleSheet, index: number, rule?: string) => {
const sendMessage = const sendMessage =
typeof rule === 'string' typeof rule === 'string'
? (nodeID: number) => ? (nodeID: number) =>
app.send(new CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref())) app.send(CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
: (nodeID: number) => app.send(new CSSDeleteRule(nodeID, index)); : (nodeID: number) => app.send(CSSDeleteRule(nodeID, index))
// TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule) // TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule)
if (stylesheet.ownerNode == null) { if (stylesheet.ownerNode == null) {
throw new Error('Owner Node not found'); throw new Error('Owner Node not found')
} }
const nodeID = app.nodes.getID(stylesheet.ownerNode); const nodeID = app.nodes.getID(stylesheet.ownerNode)
if (nodeID !== undefined) { if (nodeID !== undefined) {
sendMessage(nodeID); sendMessage(nodeID)
} // else error? } // else error?
}); })
const { insertRule, deleteRule } = CSSStyleSheet.prototype; const { insertRule, deleteRule } = CSSStyleSheet.prototype
CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) { CSSStyleSheet.prototype.insertRule = function (rule: string, index = 0) {
processOperation(this, index, rule); processOperation(this, index, rule)
return insertRule.call(this, rule, index); return insertRule.call(this, rule, index)
}; }
CSSStyleSheet.prototype.deleteRule = function (index: number) { CSSStyleSheet.prototype.deleteRule = function (index: number) {
processOperation(this, index); processOperation(this, index)
return deleteRule.call(this, index); return deleteRule.call(this, index)
}; }
app.nodes.attachNodeCallback((node: Node): void => { app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'STYLE') || !node.sheet) { if (!hasTag(node, 'STYLE') || !node.sheet) {
return; return
} }
if (node.textContent !== null && node.textContent.trim().length > 0) { if (node.textContent !== null && node.textContent.trim().length > 0) {
return; // Only fully virtual sheets maintained so far return // Only fully virtual sheets maintained so far
} }
const rules = node.sheet.cssRules; const rules = node.sheet.cssRules
for (let i = 0; i < rules.length; i++) { for (let i = 0; i < rules.length; i++) {
processOperation(node.sheet, i, rules[i].cssText); processOperation(node.sheet, i, rules[i].cssText)
} }
}); })
} }

View file

@ -1,18 +1,18 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import type Message from '../../common/messages.js'; import type Message from '../app/messages.gen.js'
import { JSException } from '../../common/messages.js'; import { JSException } from '../app/messages.gen.js'
import ErrorStackParser from 'error-stack-parser'; import ErrorStackParser from 'error-stack-parser'
export interface Options { export interface Options {
captureExceptions: boolean; captureExceptions: boolean
} }
interface StackFrame { interface StackFrame {
columnNumber?: number; columnNumber?: number
lineNumber?: number; lineNumber?: number
fileName?: string; fileName?: string
functionName?: string; functionName?: string
source?: string; source?: string
} }
function getDefaultStack(e: ErrorEvent): Array<StackFrame> { function getDefaultStack(e: ErrorEvent): Array<StackFrame> {
@ -24,15 +24,15 @@ function getDefaultStack(e: ErrorEvent): Array<StackFrame> {
functionName: '', functionName: '',
source: '', source: '',
}, },
]; ]
} }
export function getExceptionMessage(error: Error, fallbackStack: Array<StackFrame>): Message { export function getExceptionMessage(error: Error, fallbackStack: Array<StackFrame>): Message {
let stack = fallbackStack; let stack = fallbackStack
try { try {
stack = ErrorStackParser.parse(error); stack = ErrorStackParser.parse(error)
} catch (e) {} } catch (e) {}
return new JSException(error.name, error.message, JSON.stringify(stack)); return JSException(error.name, error.message, JSON.stringify(stack))
} }
export function getExceptionMessageFromEvent( export function getExceptionMessageFromEvent(
@ -40,29 +40,29 @@ export function getExceptionMessageFromEvent(
): Message | null { ): Message | null {
if (e instanceof ErrorEvent) { if (e instanceof ErrorEvent) {
if (e.error instanceof Error) { if (e.error instanceof Error) {
return getExceptionMessage(e.error, getDefaultStack(e)); return getExceptionMessage(e.error, getDefaultStack(e))
} else { } else {
let [name, message] = e.message.split(':'); let [name, message] = e.message.split(':')
if (!message) { if (!message) {
name = 'Error'; name = 'Error'
message = e.message; message = e.message
} }
return new JSException(name, message, JSON.stringify(getDefaultStack(e))); return JSException(name, message, JSON.stringify(getDefaultStack(e)))
} }
} else if ('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent) { } else if ('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent) {
if (e.reason instanceof Error) { if (e.reason instanceof Error) {
return getExceptionMessage(e.reason, []); return getExceptionMessage(e.reason, [])
} else { } else {
let message: string; let message: string
try { try {
message = JSON.stringify(e.reason); message = JSON.stringify(e.reason)
} catch (_) { } catch (_) {
message = String(e.reason); message = String(e.reason)
} }
return new JSException('Unhandled Promise Rejection', message, '[]'); return JSException('Unhandled Promise Rejection', message, '[]')
} }
} }
return null; return null
} }
export default function (app: App, opts: Partial<Options>): void { export default function (app: App, opts: Partial<Options>): void {
@ -71,18 +71,18 @@ export default function (app: App, opts: Partial<Options>): void {
captureExceptions: true, captureExceptions: true,
}, },
opts, opts,
); )
if (options.captureExceptions) { if (options.captureExceptions) {
const handler = (e: ErrorEvent | PromiseRejectionEvent): void => { const handler = (e: ErrorEvent | PromiseRejectionEvent): void => {
const msg = getExceptionMessageFromEvent(e); const msg = getExceptionMessageFromEvent(e)
if (msg != null) { if (msg != null) {
app.send(msg); app.send(msg)
} }
}; }
app.attachEventListener(window, 'unhandledrejection', (e: PromiseRejectionEvent): void => app.attachEventListener(window, 'unhandledrejection', (e: PromiseRejectionEvent): void =>
handler(e), handler(e),
); )
app.attachEventListener(window, 'error', (e: ErrorEvent): void => handler(e)); app.attachEventListener(window, 'error', (e: ErrorEvent): void => handler(e))
} }
} }

View file

@ -1,96 +1,92 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { timestamp, isURL } from '../utils.js'; import { timestamp, isURL } from '../utils.js'
import { import { ResourceTiming, SetNodeAttributeURLBased, SetNodeAttribute } from '../app/messages.gen.js'
ResourceTiming, import { hasTag } from '../app/guards.js'
SetNodeAttributeURLBased,
SetNodeAttribute,
} from '../../common/messages.js';
import { hasTag } from '../app/guards.js';
function resolveURL(url: string, location: Location = document.location) { function resolveURL(url: string, location: Location = document.location) {
url = url.trim(); url = url.trim()
if (url.startsWith('/')) { if (url.startsWith('/')) {
return location.origin + url; return location.origin + url
} else if ( } else if (
url.startsWith('http://') || url.startsWith('http://') ||
url.startsWith('https://') || url.startsWith('https://') ||
url.startsWith('data:') // any other possible value here? url.startsWith('data:') // any other possible value here?
) { ) {
return url; return url
} else { } else {
return location.origin + location.pathname + url; return location.origin + location.pathname + url
} }
} }
const PLACEHOLDER_SRC = 'https://static.openreplay.com/tracker/placeholder.jpeg'; const PLACEHOLDER_SRC = 'https://static.openreplay.com/tracker/placeholder.jpeg'
export default function (app: App): void { export default function (app: App): void {
function sendPlaceholder(id: number, node: HTMLImageElement): void { function sendPlaceholder(id: number, node: HTMLImageElement): void {
app.send(new SetNodeAttribute(id, 'src', PLACEHOLDER_SRC)); app.send(SetNodeAttribute(id, 'src', PLACEHOLDER_SRC))
const { width, height } = node.getBoundingClientRect(); const { width, height } = node.getBoundingClientRect()
if (!node.hasAttribute('width')) { if (!node.hasAttribute('width')) {
app.send(new SetNodeAttribute(id, 'width', String(width))); app.send(SetNodeAttribute(id, 'width', String(width)))
} }
if (!node.hasAttribute('height')) { if (!node.hasAttribute('height')) {
app.send(new SetNodeAttribute(id, 'height', String(height))); app.send(SetNodeAttribute(id, 'height', String(height)))
} }
} }
const sendImgSrc = app.safe(function (this: HTMLImageElement): void { const sendImgSrc = app.safe(function (this: HTMLImageElement): void {
const id = app.nodes.getID(this); const id = app.nodes.getID(this)
if (id === undefined) { if (id === undefined) {
return; return
} }
const { src, complete, naturalWidth, naturalHeight, srcset } = this; const { src, complete, naturalWidth, naturalHeight, srcset } = this
if (!complete) { if (!complete) {
return; return
} }
const resolvedSrc = resolveURL(src || ''); // Src type is null sometimes. - is it true? const resolvedSrc = resolveURL(src || '') // Src type is null sometimes. - is it true?
if (naturalWidth === 0 && naturalHeight === 0) { if (naturalWidth === 0 && naturalHeight === 0) {
if (isURL(resolvedSrc)) { if (isURL(resolvedSrc)) {
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img')); app.send(ResourceTiming(timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img'))
} }
} else if (resolvedSrc.length >= 1e5 || app.sanitizer.isMasked(id)) { } else if (resolvedSrc.length >= 1e5 || app.sanitizer.isMasked(id)) {
sendPlaceholder(id, this); sendPlaceholder(id, this)
} else { } else {
app.send(new SetNodeAttribute(id, 'src', resolvedSrc)); app.send(SetNodeAttribute(id, 'src', resolvedSrc))
if (srcset) { if (srcset) {
const resolvedSrcset = srcset const resolvedSrcset = srcset
.split(',') .split(',')
.map((str) => resolveURL(str)) .map((str) => resolveURL(str))
.join(','); .join(',')
app.send(new SetNodeAttribute(id, 'srcset', resolvedSrcset)); app.send(SetNodeAttribute(id, 'srcset', resolvedSrcset))
} }
} }
}); })
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
if (mutation.type === 'attributes') { if (mutation.type === 'attributes') {
const target = mutation.target as HTMLImageElement; const target = mutation.target as HTMLImageElement
const id = app.nodes.getID(target); const id = app.nodes.getID(target)
if (id === undefined) { if (id === undefined) {
return; return
} }
if (mutation.attributeName === 'src') { if (mutation.attributeName === 'src') {
const src = target.src; const src = target.src
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref())); app.send(SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()))
} }
if (mutation.attributeName === 'srcset') { if (mutation.attributeName === 'srcset') {
const srcset = target.srcset; const srcset = target.srcset
app.send(new SetNodeAttribute(id, 'srcset', srcset)); app.send(SetNodeAttribute(id, 'srcset', srcset))
} }
} }
} }
}); })
app.nodes.attachNodeCallback((node: Node): void => { app.nodes.attachNodeCallback((node: Node): void => {
if (!hasTag(node, 'IMG')) { if (!hasTag(node, 'IMG')) {
return; return
} }
app.nodes.attachElementListener('error', node, sendImgSrc); app.nodes.attachElementListener('error', node, sendImgSrc)
app.nodes.attachElementListener('load', node, sendImgSrc); app.nodes.attachElementListener('load', node, sendImgSrc)
sendImgSrc.call(node); sendImgSrc.call(node)
observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] }); observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] })
}); })
} }

View file

@ -1,74 +1,74 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } from '../utils.js'; import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } from '../utils.js'
import { hasTag } from '../app/guards.js'; import { hasTag } from '../app/guards.js'
import { SetInputTarget, SetInputValue, SetInputChecked } from '../../common/messages.js'; import { SetInputTarget, SetInputValue, SetInputChecked } from '../app/messages.gen.js'
const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date']; const INPUT_TYPES = ['text', 'password', 'email', 'search', 'number', 'range', 'date']
// TODO: take into consideration "contenteditable" attribute // TODO: take into consideration "contenteditable" attribute
type TextEditableElement = HTMLInputElement | HTMLTextAreaElement; type TextEditableElement = HTMLInputElement | HTMLTextAreaElement
function isTextEditable(node: any): node is TextEditableElement { function isTextEditable(node: any): node is TextEditableElement {
if (hasTag(node, 'TEXTAREA')) { if (hasTag(node, 'TEXTAREA')) {
return true; return true
} }
if (!hasTag(node, 'INPUT')) { if (!hasTag(node, 'INPUT')) {
return false; return false
} }
return INPUT_TYPES.includes(node.type); return INPUT_TYPES.includes(node.type)
} }
function isCheckable(node: any): node is HTMLInputElement { function isCheckable(node: any): node is HTMLInputElement {
if (!hasTag(node, 'INPUT')) { if (!hasTag(node, 'INPUT')) {
return false; return false
} }
const type = node.type; const type = node.type
return type === 'checkbox' || type === 'radio'; return type === 'checkbox' || type === 'radio'
} }
const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined = const labelElementFor: (element: TextEditableElement) => HTMLLabelElement | undefined =
IN_BROWSER && 'labels' in HTMLInputElement.prototype IN_BROWSER && 'labels' in HTMLInputElement.prototype
? (node) => { ? (node) => {
let p: Node | null = node; let p: Node | null = node
while ((p = p.parentNode) !== null) { while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) { if (hasTag(p, 'LABEL')) {
return p; return p
} }
} }
const labels = node.labels; const labels = node.labels
if (labels !== null && labels.length === 1) { if (labels !== null && labels.length === 1) {
return labels[0]; return labels[0]
} }
} }
: (node) => { : (node) => {
let p: Node | null = node; let p: Node | null = node
while ((p = p.parentNode) !== null) { while ((p = p.parentNode) !== null) {
if (hasTag(p, 'LABEL')) { if (hasTag(p, 'LABEL')) {
return p; return p
} }
} }
const id = node.id; const id = node.id
if (id) { if (id) {
const labels = document.querySelectorAll('label[for="' + id + '"]'); const labels = document.querySelectorAll('label[for="' + id + '"]')
if (labels !== null && labels.length === 1) { if (labels !== null && labels.length === 1) {
return labels[0] as HTMLLabelElement; return labels[0] as HTMLLabelElement
} }
} }
}; }
export function getInputLabel(node: TextEditableElement): string { export function getInputLabel(node: TextEditableElement): string {
let label = getLabelAttribute(node); let label = getLabelAttribute(node)
if (label === null) { if (label === null) {
const labelElement = labelElementFor(node); const labelElement = labelElementFor(node)
label = label =
(labelElement && labelElement.innerText) || (labelElement && labelElement.innerText) ||
node.placeholder || node.placeholder ||
node.name || node.name ||
node.id || node.id ||
node.className || node.className ||
node.type; node.type
} }
return normSpaces(label).slice(0, 100); return normSpaces(label).slice(0, 100)
} }
export declare const enum InputMode { export declare const enum InputMode {
@ -78,10 +78,10 @@ export declare const enum InputMode {
} }
export interface Options { export interface Options {
obscureInputNumbers: boolean; obscureInputNumbers: boolean
obscureInputEmails: boolean; obscureInputEmails: boolean
defaultInputMode: InputMode; defaultInputMode: InputMode
obscureInputDates: boolean; obscureInputDates: boolean
} }
export default function (app: App, opts: Partial<Options>): void { export default function (app: App, opts: Partial<Options>): void {
@ -93,18 +93,18 @@ export default function (app: App, opts: Partial<Options>): void {
obscureInputDates: false, obscureInputDates: false,
}, },
opts, opts,
); )
function sendInputTarget(id: number, node: TextEditableElement): void { function sendInputTarget(id: number, node: TextEditableElement): void {
const label = getInputLabel(node); const label = getInputLabel(node)
if (label !== '') { if (label !== '') {
app.send(new SetInputTarget(id, label)); app.send(SetInputTarget(id, label))
} }
} }
function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void { function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void {
let value = node.value; let value = node.value
let inputMode: InputMode = options.defaultInputMode; let inputMode: InputMode = options.defaultInputMode
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) { if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
inputMode = InputMode.Hidden; inputMode = InputMode.Hidden
} else if ( } else if (
hasOpenreplayAttribute(node, 'obscured') || hasOpenreplayAttribute(node, 'obscured') ||
(inputMode === InputMode.Plain && (inputMode === InputMode.Plain &&
@ -112,88 +112,88 @@ export default function (app: App, opts: Partial<Options>): void {
(options.obscureInputDates && node.type === 'date') || (options.obscureInputDates && node.type === 'date') ||
(options.obscureInputEmails && (node.type === 'email' || !!~value.indexOf('@'))))) (options.obscureInputEmails && (node.type === 'email' || !!~value.indexOf('@')))))
) { ) {
inputMode = InputMode.Obscured; inputMode = InputMode.Obscured
} }
let mask = 0; let mask = 0
switch (inputMode) { switch (inputMode) {
case InputMode.Hidden: case InputMode.Hidden:
mask = -1; mask = -1
value = ''; value = ''
break; break
case InputMode.Obscured: case InputMode.Obscured:
mask = value.length; mask = value.length
value = ''; value = ''
break; break
} }
app.send(new SetInputValue(id, value, mask)); app.send(SetInputValue(id, value, mask))
} }
const inputValues: Map<number, string> = new Map(); const inputValues: Map<number, string> = new Map()
const checkableValues: Map<number, boolean> = new Map(); const checkableValues: Map<number, boolean> = new Map()
const registeredTargets: Set<number> = new Set(); const registeredTargets: Set<number> = new Set()
app.attachStopCallback(() => { app.attachStopCallback(() => {
inputValues.clear(); inputValues.clear()
checkableValues.clear(); checkableValues.clear()
registeredTargets.clear(); registeredTargets.clear()
}); })
app.ticker.attach((): void => { app.ticker.attach((): void => {
inputValues.forEach((value, id) => { inputValues.forEach((value, id) => {
const node = app.nodes.getNode(id); const node = app.nodes.getNode(id)
if (!node) return; if (!node) return
if (!isTextEditable(node)) { if (!isTextEditable(node)) {
inputValues.delete(id); inputValues.delete(id)
return; return
} }
if (value !== node.value) { if (value !== node.value) {
inputValues.set(id, node.value); inputValues.set(id, node.value)
if (!registeredTargets.has(id)) { if (!registeredTargets.has(id)) {
registeredTargets.add(id); registeredTargets.add(id)
sendInputTarget(id, node); sendInputTarget(id, node)
} }
sendInputValue(id, node); sendInputValue(id, node)
} }
}); })
checkableValues.forEach((checked, id) => { checkableValues.forEach((checked, id) => {
const node = app.nodes.getNode(id); const node = app.nodes.getNode(id)
if (!node) return; if (!node) return
if (!isCheckable(node)) { if (!isCheckable(node)) {
checkableValues.delete(id); checkableValues.delete(id)
return; return
} }
if (checked !== node.checked) { if (checked !== node.checked) {
checkableValues.set(id, node.checked); checkableValues.set(id, node.checked)
app.send(new SetInputChecked(id, node.checked)); app.send(SetInputChecked(id, node.checked))
} }
}); })
}); })
app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets); app.ticker.attach(Set.prototype.clear, 100, false, registeredTargets)
app.nodes.attachNodeCallback( app.nodes.attachNodeCallback(
app.safe((node: Node): void => { app.safe((node: Node): void => {
const id = app.nodes.getID(node); const id = app.nodes.getID(node)
if (id === undefined) { if (id === undefined) {
return; return
} }
// TODO: support multiple select (?): use selectedOptions; Need send target? // TODO: support multiple select (?): use selectedOptions; Need send target?
if (hasTag(node, 'SELECT')) { if (hasTag(node, 'SELECT')) {
sendInputValue(id, node); sendInputValue(id, node)
app.attachEventListener(node, 'change', () => { app.attachEventListener(node, 'change', () => {
sendInputValue(id, node); sendInputValue(id, node)
}); })
} }
if (isTextEditable(node)) { if (isTextEditable(node)) {
inputValues.set(id, node.value); inputValues.set(id, node.value)
sendInputValue(id, node); sendInputValue(id, node)
return; return
} }
if (isCheckable(node)) { if (isCheckable(node)) {
checkableValues.set(id, node.checked); checkableValues.set(id, node.checked)
app.send(new SetInputChecked(id, node.checked)); app.send(SetInputChecked(id, node.checked))
return; return
} }
}), }),
); )
} }

View file

@ -1,22 +1,22 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { LongTask } from '../../common/messages.js'; import { LongTask } from '../app/messages.gen.js'
// https://w3c.github.io/performance-timeline/#the-performanceentry-interface // https://w3c.github.io/performance-timeline/#the-performanceentry-interface
interface TaskAttributionTiming extends PerformanceEntry { interface TaskAttributionTiming extends PerformanceEntry {
readonly containerType: string; readonly containerType: string
readonly containerSrc: string; readonly containerSrc: string
readonly containerId: string; readonly containerId: string
readonly containerName: string; readonly containerName: string
} }
// https://www.w3.org/TR/longtasks/#performancelongtasktiming // https://www.w3.org/TR/longtasks/#performancelongtasktiming
interface PerformanceLongTaskTiming extends PerformanceEntry { interface PerformanceLongTaskTiming extends PerformanceEntry {
readonly attribution: ReadonlyArray<TaskAttributionTiming>; readonly attribution: ReadonlyArray<TaskAttributionTiming>
} }
export default function (app: App): void { export default function (app: App): void {
if (!('PerformanceObserver' in window) || !('PerformanceLongTaskTiming' in window)) { if (!('PerformanceObserver' in window) || !('PerformanceLongTaskTiming' in window)) {
return; return
} }
const contexts: string[] = [ const contexts: string[] = [
@ -29,23 +29,23 @@ export default function (app: App): void {
'cross-origin-descendant', 'cross-origin-descendant',
'cross-origin-unreachable', 'cross-origin-unreachable',
'multiple-contexts', 'multiple-contexts',
]; ]
const containerTypes: string[] = ['window', 'iframe', 'embed', 'object']; const containerTypes: string[] = ['window', 'iframe', 'embed', 'object']
function longTask(entry: PerformanceLongTaskTiming): void { function longTask(entry: PerformanceLongTaskTiming): void {
let type = '', let type = '',
src = '', src = '',
id = '', id = '',
name = ''; name = ''
const container = entry.attribution[0]; const container = entry.attribution[0]
if (container != null) { if (container != null) {
type = container.containerType; type = container.containerType
name = container.containerName; name = container.containerName
id = container.containerId; id = container.containerId
src = container.containerSrc; src = container.containerSrc
} }
app.send( app.send(
new LongTask( LongTask(
entry.startTime + performance.timing.navigationStart, entry.startTime + performance.timing.navigationStart,
entry.duration, entry.duration,
Math.max(contexts.indexOf(entry.name), 0), Math.max(contexts.indexOf(entry.name), 0),
@ -54,11 +54,11 @@ export default function (app: App): void {
id, id,
src, src,
), ),
); )
} }
const observer: PerformanceObserver = new PerformanceObserver((list) => const observer: PerformanceObserver = new PerformanceObserver((list) =>
list.getEntries().forEach(longTask), list.getEntries().forEach(longTask),
); )
observer.observe({ entryTypes: ['longtask'] }); observer.observe({ entryTypes: ['longtask'] })
} }

View file

@ -1,15 +1,15 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { hasTag, isSVGElement } from '../app/guards.js'; import { hasTag, isSVGElement } from '../app/guards.js'
import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils.js'; import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from '../utils.js'
import { MouseMove, MouseClick } from '../../common/messages.js'; import { MouseMove, MouseClick } from '../app/messages.gen.js'
import { getInputLabel } from './input.js'; import { getInputLabel } from './input.js'
function _getSelector(target: Element): string { function _getSelector(target: Element): string {
let el: Element | null = target; let el: Element | null = target
let selector: string | null = null; let selector: string | null = null
do { do {
if (el.id) { if (el.id) {
return `#${el.id}` + (selector ? ` > ${selector}` : ''); return `#${el.id}` + (selector ? ` > ${selector}` : '')
} }
selector = selector =
el.className el.className
@ -17,17 +17,17 @@ function _getSelector(target: Element): string {
.map((cn) => cn.trim()) .map((cn) => cn.trim())
.filter((cn) => cn !== '') .filter((cn) => cn !== '')
.reduce((sel, cn) => `${sel}.${cn}`, el.tagName.toLowerCase()) + .reduce((sel, cn) => `${sel}.${cn}`, el.tagName.toLowerCase()) +
(selector ? ` > ${selector}` : ''); (selector ? ` > ${selector}` : '')
if (el === document.body) { if (el === document.body) {
return selector; return selector
} }
el = el.parentElement; el = el.parentElement
} while (el !== document.body && el !== null); } while (el !== document.body && el !== null)
return selector; return selector
} }
function isClickable(element: Element): boolean { function isClickable(element: Element): boolean {
const tag = element.tagName.toUpperCase(); const tag = element.tagName.toUpperCase()
return ( return (
tag === 'BUTTON' || tag === 'BUTTON' ||
tag === 'A' || tag === 'A' ||
@ -35,7 +35,7 @@ function isClickable(element: Element): boolean {
tag === 'SELECT' || tag === 'SELECT' ||
(element as HTMLElement).onclick != null || (element as HTMLElement).onclick != null ||
element.getAttribute('role') === 'button' element.getAttribute('role') === 'button'
); )
//|| element.className.includes("btn") //|| element.className.includes("btn")
// MBTODO: intersect addEventListener // MBTODO: intersect addEventListener
} }
@ -43,125 +43,125 @@ function isClickable(element: Element): boolean {
//TODO: fix (typescript doesn't allow work when the guard is inside the function) //TODO: fix (typescript doesn't allow work when the guard is inside the function)
function getTarget(target: EventTarget | null): Element | null { function getTarget(target: EventTarget | null): Element | null {
if (target instanceof Element) { if (target instanceof Element) {
return _getTarget(target); return _getTarget(target)
} }
return null; return null
} }
function _getTarget(target: Element): Element | null { function _getTarget(target: Element): Element | null {
let element: Element | null = target; let element: Element | null = target
while (element !== null && element !== document.documentElement) { while (element !== null && element !== document.documentElement) {
if (hasOpenreplayAttribute(element, 'masked')) { if (hasOpenreplayAttribute(element, 'masked')) {
return null; return null
} }
element = element.parentElement; element = element.parentElement
} }
if (isSVGElement(target)) { if (isSVGElement(target)) {
let owner = target.ownerSVGElement; let owner = target.ownerSVGElement
while (owner !== null) { while (owner !== null) {
target = owner; target = owner
owner = owner.ownerSVGElement; owner = owner.ownerSVGElement
} }
} }
element = target; element = target
while (element !== null && element !== document.documentElement) { while (element !== null && element !== document.documentElement) {
const tag = element.tagName.toUpperCase(); const tag = element.tagName.toUpperCase()
if (tag === 'LABEL') { if (tag === 'LABEL') {
return null; return null
} }
if (tag === 'INPUT') { if (tag === 'INPUT') {
return element; return element
} }
if (isClickable(element) || getLabelAttribute(element) !== null) { if (isClickable(element) || getLabelAttribute(element) !== null) {
return element; return element
} }
element = element.parentElement; element = element.parentElement
} }
return target === document.documentElement ? null : target; return target === document.documentElement ? null : target
} }
export default function (app: App): void { export default function (app: App): void {
function getTargetLabel(target: Element): string { function getTargetLabel(target: Element): string {
const dl = getLabelAttribute(target); const dl = getLabelAttribute(target)
if (dl !== null) { if (dl !== null) {
return dl; return dl
} }
if (hasTag(target, 'INPUT')) { if (hasTag(target, 'INPUT')) {
return getInputLabel(target); return getInputLabel(target)
} }
if (isClickable(target)) { if (isClickable(target)) {
let label = ''; let label = ''
if (target instanceof HTMLElement) { if (target instanceof HTMLElement) {
label = app.sanitizer.getInnerTextSecure(target); label = app.sanitizer.getInnerTextSecure(target)
} }
label = label || target.id || target.className; label = label || target.id || target.className
return normSpaces(label).slice(0, 100); return normSpaces(label).slice(0, 100)
} }
return ''; return ''
} }
let mousePositionX = -1; let mousePositionX = -1
let mousePositionY = -1; let mousePositionY = -1
let mousePositionChanged = false; let mousePositionChanged = false
let mouseTarget: Element | null = null; let mouseTarget: Element | null = null
let mouseTargetTime = 0; let mouseTargetTime = 0
app.attachStopCallback(() => { app.attachStopCallback(() => {
mousePositionX = -1; mousePositionX = -1
mousePositionY = -1; mousePositionY = -1
mousePositionChanged = false; mousePositionChanged = false
mouseTarget = null; mouseTarget = null
}); })
const sendMouseMove = (): void => { const sendMouseMove = (): void => {
if (mousePositionChanged) { if (mousePositionChanged) {
app.send(new MouseMove(mousePositionX, mousePositionY)); app.send(MouseMove(mousePositionX, mousePositionY))
mousePositionChanged = false; mousePositionChanged = false
} }
}; }
const selectorMap: { [id: number]: string } = {}; const selectorMap: { [id: number]: string } = {}
function getSelector(id: number, target: Element): string { function getSelector(id: number, target: Element): string {
return (selectorMap[id] = selectorMap[id] || _getSelector(target)); return (selectorMap[id] = selectorMap[id] || _getSelector(target))
} }
app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => { app.attachEventListener(document.documentElement, 'mouseover', (e: MouseEvent): void => {
const target = getTarget(e.target); const target = getTarget(e.target)
if (target !== mouseTarget) { if (target !== mouseTarget) {
mouseTarget = target; mouseTarget = target
mouseTargetTime = performance.now(); mouseTargetTime = performance.now()
} }
}); })
app.attachEventListener( app.attachEventListener(
document, document,
'mousemove', 'mousemove',
(e: MouseEvent): void => { (e: MouseEvent): void => {
mousePositionX = e.clientX; mousePositionX = e.clientX
mousePositionY = e.clientY; mousePositionY = e.clientY
mousePositionChanged = true; mousePositionChanged = true
}, },
false, false,
); )
app.attachEventListener(document, 'click', (e: MouseEvent): void => { app.attachEventListener(document, 'click', (e: MouseEvent): void => {
const target = getTarget(e.target); const target = getTarget(e.target)
if ((!e.clientX && !e.clientY) || target === null) { if ((!e.clientX && !e.clientY) || target === null) {
return; return
} }
const id = app.nodes.getID(target); const id = app.nodes.getID(target)
if (id !== undefined) { if (id !== undefined) {
sendMouseMove(); sendMouseMove()
app.send( app.send(
new MouseClick( MouseClick(
id, id,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0, mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
getTargetLabel(target), getTargetLabel(target),
getSelector(id, target), getSelector(id, target),
), ),
true, true,
); )
} }
mouseTarget = null; mouseTarget = null
}); })
app.ticker.attach(sendMouseMove, 10); app.ticker.attach(sendMouseMove, 10)
} }

View file

@ -1,25 +1,25 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { IN_BROWSER } from '../utils.js'; import { IN_BROWSER } from '../utils.js'
import { PerformanceTrack } from '../../common/messages.js'; import { PerformanceTrack } from '../app/messages.gen.js'
type Perf = { type Perf = {
memory: { memory: {
totalJSHeapSize?: number; totalJSHeapSize?: number
usedJSHeapSize?: number; usedJSHeapSize?: number
jsHeapSizeLimit?: number; jsHeapSizeLimit?: number
}; }
}; }
const perf: Perf = const perf: Perf =
IN_BROWSER && 'performance' in window && 'memory' in performance // works in Chrome only IN_BROWSER && 'performance' in window && 'memory' in performance // works in Chrome only
? (performance as any) ? (performance as any)
: { memory: {} }; : { memory: {} }
export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0; export const deviceMemory = IN_BROWSER ? ((navigator as any).deviceMemory || 0) * 1024 : 0
export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0; export const jsHeapSizeLimit = perf.memory.jsHeapSizeLimit || 0
export interface Options { export interface Options {
capturePerformance: boolean; capturePerformance: boolean
} }
export default function (app: App, opts: Partial<Options>): void { export default function (app: App, opts: Partial<Options>): void {
@ -28,59 +28,59 @@ export default function (app: App, opts: Partial<Options>): void {
capturePerformance: true, capturePerformance: true,
}, },
opts, opts,
); )
if (!options.capturePerformance) { if (!options.capturePerformance) {
return; return
} }
let frames: number | undefined; let frames: number | undefined
let ticks: number | undefined; let ticks: number | undefined
const nextFrame = (): void => { const nextFrame = (): void => {
if (frames === undefined || frames === -1) { if (frames === undefined || frames === -1) {
return; return
} }
frames++; frames++
requestAnimationFrame(nextFrame); requestAnimationFrame(nextFrame)
}; }
app.ticker.attach( app.ticker.attach(
(): void => { (): void => {
if (ticks === undefined || ticks === -1) { if (ticks === undefined || ticks === -1) {
return; return
} }
ticks++; ticks++
}, },
0, 0,
false, false,
); )
const sendPerformanceTrack = (): void => { const sendPerformanceTrack = (): void => {
if (frames === undefined || ticks === undefined) { if (frames === undefined || ticks === undefined) {
return; return
} }
app.send( app.send(
new PerformanceTrack( PerformanceTrack(
frames, frames,
ticks, ticks,
perf.memory.totalJSHeapSize || 0, perf.memory.totalJSHeapSize || 0,
perf.memory.usedJSHeapSize || 0, perf.memory.usedJSHeapSize || 0,
), ),
); )
ticks = frames = document.hidden ? -1 : 0; ticks = frames = document.hidden ? -1 : 0
}; }
app.attachStartCallback((): void => { app.attachStartCallback((): void => {
ticks = frames = -1; ticks = frames = -1
sendPerformanceTrack(); sendPerformanceTrack()
nextFrame(); nextFrame()
}); })
app.attachStopCallback((): void => { app.attachStopCallback((): void => {
ticks = frames = undefined; ticks = frames = undefined
}); })
app.ticker.attach(sendPerformanceTrack, 40, false); app.ticker.attach(sendPerformanceTrack, 40, false)
if (document.hidden !== undefined) { if (document.hidden !== undefined) {
app.attachEventListener( app.attachEventListener(
@ -89,6 +89,6 @@ export default function (app: App, opts: Partial<Options>): void {
sendPerformanceTrack as EventListener, sendPerformanceTrack as EventListener,
false, false,
false, false,
); )
} }
} }

View file

@ -1,14 +1,14 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { SetViewportScroll, SetNodeScroll } from '../../common/messages.js'; import { SetViewportScroll, SetNodeScroll } from '../app/messages.gen.js'
import { isElementNode } from '../app/guards.js'; import { isElementNode } from '../app/guards.js'
export default function (app: App): void { export default function (app: App): void {
let documentScroll = false; let documentScroll = false
const nodeScroll: Map<Element, [number, number]> = new Map(); const nodeScroll: Map<Element, [number, number]> = new Map()
const sendSetViewportScroll = app.safe((): void => const sendSetViewportScroll = app.safe((): void =>
app.send( app.send(
new SetViewportScroll( SetViewportScroll(
window.pageXOffset || window.pageXOffset ||
(document.documentElement && document.documentElement.scrollLeft) || (document.documentElement && document.documentElement.scrollLeft) ||
(document.body && document.body.scrollLeft) || (document.body && document.body.scrollLeft) ||
@ -19,49 +19,49 @@ export default function (app: App): void {
0, 0,
), ),
), ),
); )
const sendSetNodeScroll = app.safe((s: [number, number], node: Node): void => { const sendSetNodeScroll = app.safe((s: [number, number], node: Node): void => {
const id = app.nodes.getID(node); const id = app.nodes.getID(node)
if (id !== undefined) { if (id !== undefined) {
app.send(new SetNodeScroll(id, s[0], s[1])); app.send(SetNodeScroll(id, s[0], s[1]))
} }
}); })
app.attachStartCallback(sendSetViewportScroll); app.attachStartCallback(sendSetViewportScroll)
app.attachStopCallback(() => { app.attachStopCallback(() => {
documentScroll = false; documentScroll = false
nodeScroll.clear(); nodeScroll.clear()
}); })
app.nodes.attachNodeCallback((node, isStart) => { app.nodes.attachNodeCallback((node, isStart) => {
if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) { if (isStart && isElementNode(node) && node.scrollLeft + node.scrollTop > 0) {
nodeScroll.set(node, [node.scrollLeft, node.scrollTop]); nodeScroll.set(node, [node.scrollLeft, node.scrollTop])
} }
}); })
app.attachEventListener(window, 'scroll', (e: Event): void => { app.attachEventListener(window, 'scroll', (e: Event): void => {
const target = e.target; const target = e.target
if (target === document) { if (target === document) {
documentScroll = true; documentScroll = true
return; return
} }
if (target instanceof Element) { if (target instanceof Element) {
nodeScroll.set(target, [target.scrollLeft, target.scrollTop]); nodeScroll.set(target, [target.scrollLeft, target.scrollTop])
} }
}); })
app.ticker.attach( app.ticker.attach(
(): void => { (): void => {
if (documentScroll) { if (documentScroll) {
sendSetViewportScroll(); sendSetViewportScroll()
documentScroll = false; documentScroll = false
} }
nodeScroll.forEach(sendSetNodeScroll); nodeScroll.forEach(sendSetNodeScroll)
nodeScroll.clear(); nodeScroll.clear()
}, },
5, 5,
false, false,
); )
} }

View file

@ -1,62 +1,62 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { hasTag } from '../app/guards.js'; import { hasTag } from '../app/guards.js'
import { isURL } from '../utils.js'; import { isURL } from '../utils.js'
import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../../common/messages.js'; import { ResourceTiming, PageLoadTiming, PageRenderTiming } from '../app/messages.gen.js'
// Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js // Inspired by https://github.com/WPO-Foundation/RUM-SpeedIndex/blob/master/src/rum-speedindex.js
interface ResourcesTimeMap { interface ResourcesTimeMap {
[k: string]: number; [k: string]: number
} }
interface PaintBlock { interface PaintBlock {
time: number; time: number
area: number; area: number
} }
function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> { function getPaintBlocks(resources: ResourcesTimeMap): Array<PaintBlock> {
const paintBlocks: Array<PaintBlock> = []; const paintBlocks: Array<PaintBlock> = []
const elements = document.getElementsByTagName('*'); const elements = document.getElementsByTagName('*')
const styleURL = /url\(("[^"]*"|'[^']*'|[^)]*)\)/i; const styleURL = /url\(("[^"]*"|'[^']*'|[^)]*)\)/i
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < elements.length; i++) {
const element = elements[i]; const element = elements[i]
let src = ''; let src = ''
if (hasTag(element, 'IMG')) { if (hasTag(element, 'IMG')) {
src = element.currentSrc || element.src; src = element.currentSrc || element.src
} }
if (!src) { if (!src) {
const backgroundImage = getComputedStyle(element).getPropertyValue('background-image'); const backgroundImage = getComputedStyle(element).getPropertyValue('background-image')
if (backgroundImage) { if (backgroundImage) {
const matches = styleURL.exec(backgroundImage); const matches = styleURL.exec(backgroundImage)
if (matches !== null) { if (matches !== null) {
src = matches[1]; src = matches[1]
if (src.startsWith('"') || src.startsWith("'")) { if (src.startsWith('"') || src.startsWith("'")) {
src = src.substr(1, src.length - 2); src = src.substr(1, src.length - 2)
} }
} }
} }
} }
if (!src) continue; if (!src) continue
const time = src.substr(0, 10) === 'data:image' ? 0 : resources[src]; const time = src.substr(0, 10) === 'data:image' ? 0 : resources[src]
if (time === undefined) continue; if (time === undefined) continue
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect()
const top = Math.max(rect.top, 0); const top = Math.max(rect.top, 0)
const left = Math.max(rect.left, 0); const left = Math.max(rect.left, 0)
const bottom = Math.min( const bottom = Math.min(
rect.bottom, rect.bottom,
window.innerHeight || window.innerHeight ||
(document.documentElement && document.documentElement.clientHeight) || (document.documentElement && document.documentElement.clientHeight) ||
0, 0,
); )
const right = Math.min( const right = Math.min(
rect.right, rect.right,
window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0, window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0,
); )
if (bottom <= top || right <= left) continue; if (bottom <= top || right <= left) continue
const area = (bottom - top) * (right - left); const area = (bottom - top) * (right - left)
paintBlocks.push({ time, area }); paintBlocks.push({ time, area })
} }
return paintBlocks; return paintBlocks
} }
function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<PaintBlock>): number { function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<PaintBlock>): number {
@ -69,20 +69,20 @@ function calculateSpeedIndex(firstContentfulPaint: number, paintBlocks: Array<Pa
(document.documentElement && document.documentElement.clientHeight) || 0, (document.documentElement && document.documentElement.clientHeight) || 0,
window.innerHeight || 0, window.innerHeight || 0,
)) / )) /
10; 10
let s = a * firstContentfulPaint; let s = a * firstContentfulPaint
for (let i = 0; i < paintBlocks.length; i++) { for (let i = 0; i < paintBlocks.length; i++) {
const { time, area } = paintBlocks[i]; const { time, area } = paintBlocks[i]
a += area; a += area
s += area * (time > firstContentfulPaint ? time : firstContentfulPaint); s += area * (time > firstContentfulPaint ? time : firstContentfulPaint)
} }
return a === 0 ? 0 : s / a; return a === 0 ? 0 : s / a
} }
export interface Options { export interface Options {
captureResourceTimings: boolean; captureResourceTimings: boolean
capturePageLoadTimings: boolean; capturePageLoadTimings: boolean
capturePageRenderTimings: boolean; capturePageRenderTimings: boolean
} }
export default function (app: App, opts: Partial<Options>): void { export default function (app: App, opts: Partial<Options>): void {
@ -93,23 +93,23 @@ export default function (app: App, opts: Partial<Options>): void {
capturePageRenderTimings: true, capturePageRenderTimings: true,
}, },
opts, opts,
); )
if (!('PerformanceObserver' in window)) { if (!('PerformanceObserver' in window)) {
options.captureResourceTimings = false; options.captureResourceTimings = false
} }
if (!options.captureResourceTimings) { if (!options.captureResourceTimings) {
return; return
} // Resources are necessary for all timings } // Resources are necessary for all timings
let resources: ResourcesTimeMap | null = {}; let resources: ResourcesTimeMap | null = {}
function resourceTiming(entry: PerformanceResourceTiming): void { function resourceTiming(entry: PerformanceResourceTiming): void {
if (entry.duration < 0 || !isURL(entry.name) || app.isServiceURL(entry.name)) return; if (entry.duration < 0 || !isURL(entry.name) || app.isServiceURL(entry.name)) return
if (resources !== null) { if (resources !== null) {
resources[entry.name] = entry.startTime + entry.duration; resources[entry.name] = entry.startTime + entry.duration
} }
app.send( app.send(
new ResourceTiming( ResourceTiming(
entry.startTime + performance.timing.navigationStart, entry.startTime + performance.timing.navigationStart,
entry.duration, entry.duration,
entry.responseStart && entry.startTime ? entry.responseStart - entry.startTime : 0, entry.responseStart && entry.startTime ? entry.responseStart - entry.startTime : 0,
@ -119,51 +119,51 @@ export default function (app: App, opts: Partial<Options>): void {
entry.name, entry.name,
entry.initiatorType, entry.initiatorType,
), ),
); )
} }
const observer: PerformanceObserver = new PerformanceObserver((list) => const observer: PerformanceObserver = new PerformanceObserver((list) =>
list.getEntries().forEach(resourceTiming), list.getEntries().forEach(resourceTiming),
); )
let prevSessionID: string | undefined; let prevSessionID: string | undefined
app.attachStartCallback(function ({ sessionID }) { app.attachStartCallback(function ({ sessionID }) {
if (sessionID !== prevSessionID) { if (sessionID !== prevSessionID) {
// Send past page resources on a newly started session // Send past page resources on a newly started session
performance.getEntriesByType('resource').forEach(resourceTiming); performance.getEntriesByType('resource').forEach(resourceTiming)
prevSessionID = sessionID; prevSessionID = sessionID
} }
observer.observe({ entryTypes: ['resource'] }); observer.observe({ entryTypes: ['resource'] })
}); })
app.attachStopCallback(function () { app.attachStopCallback(function () {
observer.disconnect(); observer.disconnect()
}); })
let firstPaint = 0, let firstPaint = 0,
firstContentfulPaint = 0; firstContentfulPaint = 0
if (options.capturePageLoadTimings) { if (options.capturePageLoadTimings) {
let pageLoadTimingSent = false; let pageLoadTimingSent = false
app.ticker.attach(() => { app.ticker.attach(() => {
if (pageLoadTimingSent) { if (pageLoadTimingSent) {
return; return
} }
if (firstPaint === 0 || firstContentfulPaint === 0) { if (firstPaint === 0 || firstContentfulPaint === 0) {
performance.getEntriesByType('paint').forEach((entry: PerformanceEntry) => { performance.getEntriesByType('paint').forEach((entry: PerformanceEntry) => {
const { name, startTime } = entry; const { name, startTime } = entry
switch (name) { switch (name) {
case 'first-paint': case 'first-paint':
firstPaint = startTime; firstPaint = startTime
break; break
case 'first-contentful-paint': case 'first-contentful-paint':
firstContentfulPaint = startTime; firstContentfulPaint = startTime
break; break
} }
}); })
} }
if (performance.timing.loadEventEnd || performance.now() > 30000) { if (performance.timing.loadEventEnd || performance.now() > 30000) {
pageLoadTimingSent = true; pageLoadTimingSent = true
const { const {
navigationStart, navigationStart,
requestStart, requestStart,
@ -173,9 +173,9 @@ export default function (app: App, opts: Partial<Options>): void {
domContentLoadedEventEnd, domContentLoadedEventEnd,
loadEventStart, loadEventStart,
loadEventEnd, loadEventEnd,
} = performance.timing; } = performance.timing
app.send( app.send(
new PageLoadTiming( PageLoadTiming(
requestStart - navigationStart || 0, requestStart - navigationStart || 0,
responseStart - navigationStart || 0, responseStart - navigationStart || 0,
responseEnd - navigationStart || 0, responseEnd - navigationStart || 0,
@ -186,46 +186,46 @@ export default function (app: App, opts: Partial<Options>): void {
firstPaint, firstPaint,
firstContentfulPaint, firstContentfulPaint,
), ),
); )
} }
}, 30); }, 30)
} }
if (options.capturePageRenderTimings) { if (options.capturePageRenderTimings) {
let visuallyComplete = 0, let visuallyComplete = 0,
interactiveWindowStartTime = 0, interactiveWindowStartTime = 0,
interactiveWindowTickTime: number | null = 0, interactiveWindowTickTime: number | null = 0,
paintBlocks: Array<PaintBlock> | null = null; paintBlocks: Array<PaintBlock> | null = null
let pageRenderTimingSent = false; let pageRenderTimingSent = false
app.ticker.attach(() => { app.ticker.attach(() => {
if (pageRenderTimingSent) { if (pageRenderTimingSent) {
return; return
} }
const time = performance.now(); const time = performance.now()
if (resources !== null) { if (resources !== null) {
visuallyComplete = Math.max.apply( visuallyComplete = Math.max.apply(
null, null,
Object.keys(resources).map((k) => (resources as any)[k]), Object.keys(resources).map((k) => (resources as any)[k]),
); )
if (time - visuallyComplete > 1000) { if (time - visuallyComplete > 1000) {
paintBlocks = getPaintBlocks(resources); paintBlocks = getPaintBlocks(resources)
resources = null; resources = null
} }
} }
if (interactiveWindowTickTime !== null) { if (interactiveWindowTickTime !== null) {
if (time - interactiveWindowTickTime > 50) { if (time - interactiveWindowTickTime > 50) {
interactiveWindowStartTime = time; interactiveWindowStartTime = time
} }
interactiveWindowTickTime = time - interactiveWindowStartTime > 5000 ? null : time; interactiveWindowTickTime = time - interactiveWindowStartTime > 5000 ? null : time
} }
if ((paintBlocks !== null && interactiveWindowTickTime === null) || time > 30000) { if ((paintBlocks !== null && interactiveWindowTickTime === null) || time > 30000) {
pageRenderTimingSent = true; pageRenderTimingSent = true
resources = null; resources = null
const speedIndex = const speedIndex =
paintBlocks === null paintBlocks === null
? 0 ? 0
: calculateSpeedIndex(firstContentfulPaint || firstPaint, paintBlocks); : calculateSpeedIndex(firstContentfulPaint || firstPaint, paintBlocks)
const timeToInteractive = const timeToInteractive =
interactiveWindowTickTime === null interactiveWindowTickTime === null
? Math.max( ? Math.max(
@ -234,15 +234,15 @@ export default function (app: App, opts: Partial<Options>): void {
performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart || performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart ||
0, 0,
) )
: 0; : 0
app.send( app.send(
new PageRenderTiming( PageRenderTiming(
speedIndex, speedIndex,
firstContentfulPaint > visuallyComplete ? firstContentfulPaint : visuallyComplete, firstContentfulPaint > visuallyComplete ? firstContentfulPaint : visuallyComplete,
timeToInteractive, timeToInteractive,
), ),
); )
} }
}); })
} }
} }

View file

@ -1,40 +1,40 @@
import type App from '../app/index.js'; import type App from '../app/index.js'
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../../common/messages.js'; import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js'
export default function (app: App): void { export default function (app: App): void {
let url: string, width: number, height: number; let url: string, width: number, height: number
let navigationStart = performance.timing.navigationStart; let navigationStart = performance.timing.navigationStart
const sendSetPageLocation = app.safe(() => { const sendSetPageLocation = app.safe(() => {
const { URL } = document; const { URL } = document
if (URL !== url) { if (URL !== url) {
url = URL; url = URL
app.send(new SetPageLocation(url, document.referrer, navigationStart)); app.send(SetPageLocation(url, document.referrer, navigationStart))
navigationStart = 0; navigationStart = 0
} }
}); })
const sendSetViewportSize = app.safe(() => { const sendSetViewportSize = app.safe(() => {
const { innerWidth, innerHeight } = window; const { innerWidth, innerHeight } = window
if (innerWidth !== width || innerHeight !== height) { if (innerWidth !== width || innerHeight !== height) {
width = innerWidth; width = innerWidth
height = innerHeight; height = innerHeight
app.send(new SetViewportSize(width, height)); app.send(SetViewportSize(width, height))
} }
}); })
const sendSetPageVisibility = const sendSetPageVisibility =
document.hidden === undefined document.hidden === undefined
? Function.prototype ? Function.prototype
: app.safe(() => app.send(new SetPageVisibility(document.hidden))); : app.safe(() => app.send(SetPageVisibility(document.hidden)))
app.attachStartCallback(() => { app.attachStartCallback(() => {
url = ''; url = ''
width = height = -1; width = height = -1
sendSetPageLocation(); sendSetPageLocation()
sendSetViewportSize(); sendSetViewportSize()
sendSetPageVisibility(); sendSetPageVisibility()
}); })
if (document.hidden !== undefined) { if (document.hidden !== undefined) {
app.attachEventListener( app.attachEventListener(
@ -43,9 +43,9 @@ export default function (app: App): void {
sendSetPageVisibility as EventListener, sendSetPageVisibility as EventListener,
false, false,
false, false,
); )
} }
app.ticker.attach(sendSetPageLocation, 1, false); app.ticker.attach(sendSetPageLocation, 1, false)
app.ticker.attach(sendSetViewportSize, 5, false); app.ticker.attach(sendSetViewportSize, 5, false)
} }

View file

@ -1,65 +1,65 @@
export function timestamp(): number { export function timestamp(): number {
return Math.round(performance.now()) + performance.timing.navigationStart; return Math.round(performance.now()) + performance.timing.navigationStart
} }
export const stars: (str: string) => string = export const stars: (str: string) => string =
'repeat' in String.prototype 'repeat' in String.prototype
? (str: string): string => '*'.repeat(str.length) ? (str: string): string => '*'.repeat(str.length)
: (str: string): string => str.replace(/./g, '*'); : (str: string): string => str.replace(/./g, '*')
export function normSpaces(str: string): string { export function normSpaces(str: string): string {
return str.trim().replace(/\s+/g, ' '); return str.trim().replace(/\s+/g, ' ')
} }
// isAbsoluteUrl regexp: /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url) // isAbsoluteUrl regexp: /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url)
export function isURL(s: string): boolean { export function isURL(s: string): boolean {
return s.startsWith('https://') || s.startsWith('http://'); return s.startsWith('https://') || s.startsWith('http://')
} }
export const IN_BROWSER = !(typeof window === 'undefined'); export const IN_BROWSER = !(typeof window === 'undefined')
// TODO: JOIN IT WITH LOGGER somehow (use logging decorators?); Don't forget about index.js loggin when there is no logger instance. // TODO: JOIN IT WITH LOGGER somehow (use logging decorators?); Don't forget about index.js loggin when there is no logger instance.
export const DOCS_HOST = 'https://docs.openreplay.com'; export const DOCS_HOST = 'https://docs.openreplay.com'
const warnedFeatures: { [key: string]: boolean } = {}; const warnedFeatures: { [key: string]: boolean } = {}
export function deprecationWarn(nameOfFeature: string, useInstead: string, docsPath = '/'): void { export function deprecationWarn(nameOfFeature: string, useInstead: string, docsPath = '/'): void {
if (warnedFeatures[nameOfFeature]) { if (warnedFeatures[nameOfFeature]) {
return; return
} }
console.warn( console.warn(
`OpenReplay: ${nameOfFeature} is deprecated. ${ `OpenReplay: ${nameOfFeature} is deprecated. ${
useInstead ? `Please, use ${useInstead} instead.` : '' useInstead ? `Please, use ${useInstead} instead.` : ''
} Visit ${DOCS_HOST}${docsPath} for more information.`, } Visit ${DOCS_HOST}${docsPath} for more information.`,
); )
warnedFeatures[nameOfFeature] = true; warnedFeatures[nameOfFeature] = true
} }
export function getLabelAttribute(e: Element): string | null { export function getLabelAttribute(e: Element): string | null {
let value = e.getAttribute('data-openreplay-label'); let value = e.getAttribute('data-openreplay-label')
if (value !== null) { if (value !== null) {
return value; return value
} }
value = e.getAttribute('data-asayer-label'); value = e.getAttribute('data-asayer-label')
if (value !== null) { if (value !== null) {
deprecationWarn('"data-asayer-label" attribute', '"data-openreplay-label" attribute', '/'); deprecationWarn('"data-asayer-label" attribute', '"data-openreplay-label" attribute', '/')
} }
return value; return value
} }
export function hasOpenreplayAttribute(e: Element, name: string): boolean { export function hasOpenreplayAttribute(e: Element, name: string): boolean {
const newName = `data-openreplay-${name}`; const newName = `data-openreplay-${name}`
if (e.hasAttribute(newName)) { if (e.hasAttribute(newName)) {
return true; return true
} }
const oldName = `data-asayer-${name}`; const oldName = `data-asayer-${name}`
if (e.hasAttribute(oldName)) { if (e.hasAttribute(oldName)) {
deprecationWarn( deprecationWarn(
`"${oldName}" attribute`, `"${oldName}" attribute`,
`"${newName}" attribute`, `"${newName}" attribute`,
'/installation/sanitize-data', '/installation/sanitize-data',
); )
return true; return true
} }
return false; return false
} }

View file

@ -1,10 +1,10 @@
type Node = { type Node = {
name: string; name: string
penalty: number; penalty: number
level?: number; level?: number
}; }
type Path = Node[]; type Path = Node[]
enum Limit { enum Limit {
All, All,
@ -13,27 +13,27 @@ enum Limit {
} }
export type Options = { export type Options = {
root: Element; root: Element
idName: (name: string) => boolean; idName: (name: string) => boolean
className: (name: string) => boolean; className: (name: string) => boolean
tagName: (name: string) => boolean; tagName: (name: string) => boolean
attr: (name: string, value: string) => boolean; attr: (name: string, value: string) => boolean
seedMinLength: number; seedMinLength: number
optimizedMinLength: number; optimizedMinLength: number
threshold: number; threshold: number
maxNumberOfTries: number; maxNumberOfTries: number
}; }
let config: Options; let config: Options
let rootDocument: Document | Element; let rootDocument: Document | Element
export function finder(input: Element, options?: Partial<Options>) { export function finder(input: Element, options?: Partial<Options>) {
if (input.nodeType !== Node.ELEMENT_NODE) { if (input.nodeType !== Node.ELEMENT_NODE) {
throw new Error("Can't generate CSS selector for non-element node type."); throw new Error("Can't generate CSS selector for non-element node type.")
} }
if ('html' === input.tagName.toLowerCase()) { if ('html' === input.tagName.toLowerCase()) {
return 'html'; return 'html'
} }
const defaults: Options = { const defaults: Options = {
@ -46,264 +46,264 @@ export function finder(input: Element, options?: Partial<Options>) {
optimizedMinLength: 2, optimizedMinLength: 2,
threshold: 1000, threshold: 1000,
maxNumberOfTries: 10000, maxNumberOfTries: 10000,
}; }
config = { ...defaults, ...options }; config = { ...defaults, ...options }
rootDocument = findRootDocument(config.root, defaults); rootDocument = findRootDocument(config.root, defaults)
let path = bottomUpSearch(input, Limit.All, () => let path = bottomUpSearch(input, Limit.All, () =>
bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)), bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)),
); )
if (path) { if (path) {
const optimized = sort(optimize(path, input)); const optimized = sort(optimize(path, input))
if (optimized.length > 0) { if (optimized.length > 0) {
path = optimized[0]; path = optimized[0]
} }
return selector(path); return selector(path)
} else { } else {
throw new Error('Selector was not found.'); throw new Error('Selector was not found.')
} }
} }
function findRootDocument(rootNode: Element | Document, defaults: Options) { function findRootDocument(rootNode: Element | Document, defaults: Options) {
if (rootNode.nodeType === Node.DOCUMENT_NODE) { if (rootNode.nodeType === Node.DOCUMENT_NODE) {
return rootNode; return rootNode
} }
if (rootNode === defaults.root) { if (rootNode === defaults.root) {
return rootNode.ownerDocument; return rootNode.ownerDocument
} }
return rootNode; return rootNode
} }
function bottomUpSearch(input: Element, limit: Limit, fallback?: () => Path | null): Path | null { function bottomUpSearch(input: Element, limit: Limit, fallback?: () => Path | null): Path | null {
let path: Path | null = null; let path: Path | null = null
const stack: Node[][] = []; const stack: Node[][] = []
let current: Element | null = input; let current: Element | null = input
let i = 0; let i = 0
while (current && current !== config.root.parentElement) { while (current && current !== config.root.parentElement) {
let level: Node[] = maybe(id(current)) || let level: Node[] = maybe(id(current)) ||
maybe(...attr(current)) || maybe(...attr(current)) ||
maybe(...classNames(current)) || maybe(...classNames(current)) ||
maybe(tagName(current)) || [any()]; maybe(tagName(current)) || [any()]
const nth = index(current); const nth = index(current)
if (limit === Limit.All) { if (limit === Limit.All) {
if (nth) { if (nth) {
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)))
} }
} else if (limit === Limit.Two) { } else if (limit === Limit.Two) {
level = level.slice(0, 1); level = level.slice(0, 1)
if (nth) { if (nth) {
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)))
} }
} else if (limit === Limit.One) { } else if (limit === Limit.One) {
const [node] = (level = level.slice(0, 1)); const [node] = (level = level.slice(0, 1))
if (nth && dispensableNth(node)) { if (nth && dispensableNth(node)) {
level = [nthChild(node, nth)]; level = [nthChild(node, nth)]
} }
} }
for (const node of level) { for (const node of level) {
node.level = i; node.level = i
} }
stack.push(level); stack.push(level)
if (stack.length >= config.seedMinLength) { if (stack.length >= config.seedMinLength) {
path = findUniquePath(stack, fallback); path = findUniquePath(stack, fallback)
if (path) { if (path) {
break; break
} }
} }
current = current.parentElement; current = current.parentElement
i++; i++
} }
if (!path) { if (!path) {
path = findUniquePath(stack, fallback); path = findUniquePath(stack, fallback)
} }
return path; return path
} }
function findUniquePath(stack: Node[][], fallback?: () => Path | null): Path | null { function findUniquePath(stack: Node[][], fallback?: () => Path | null): Path | null {
const paths = sort(combinations(stack)); const paths = sort(combinations(stack))
if (paths.length > config.threshold) { if (paths.length > config.threshold) {
return fallback ? fallback() : null; return fallback ? fallback() : null
} }
for (const candidate of paths) { for (const candidate of paths) {
if (unique(candidate)) { if (unique(candidate)) {
return candidate; return candidate
} }
} }
return null; return null
} }
function selector(path: Path): string { function selector(path: Path): string {
let node = path[0]; let node = path[0]
let query = node.name; let query = node.name
for (let i = 1; i < path.length; i++) { for (let i = 1; i < path.length; i++) {
const level = path[i].level || 0; const level = path[i].level || 0
if (node.level === level - 1) { if (node.level === level - 1) {
query = `${path[i].name} > ${query}`; query = `${path[i].name} > ${query}`
} else { } else {
query = `${path[i].name} ${query}`; query = `${path[i].name} ${query}`
} }
node = path[i]; node = path[i]
} }
return query; return query
} }
function penalty(path: Path): number { function penalty(path: Path): number {
return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0); return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0)
} }
function unique(path: Path) { function unique(path: Path) {
switch (rootDocument.querySelectorAll(selector(path)).length) { switch (rootDocument.querySelectorAll(selector(path)).length) {
case 0: case 0:
throw new Error(`Can't select any node with this selector: ${selector(path)}`); throw new Error(`Can't select any node with this selector: ${selector(path)}`)
case 1: case 1:
return true; return true
default: default:
return false; return false
} }
} }
function id(input: Element): Node | null { function id(input: Element): Node | null {
const elementId = input.getAttribute('id'); const elementId = input.getAttribute('id')
if (elementId && config.idName(elementId)) { if (elementId && config.idName(elementId)) {
return { return {
name: '#' + cssesc(elementId, { isIdentifier: true }), name: '#' + cssesc(elementId, { isIdentifier: true }),
penalty: 0, penalty: 0,
}; }
} }
return null; return null
} }
function attr(input: Element): Node[] { function attr(input: Element): Node[] {
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value)); const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value))
return attrs.map( return attrs.map(
(attr): Node => ({ (attr): Node => ({
name: '[' + cssesc(attr.name, { isIdentifier: true }) + '="' + cssesc(attr.value) + '"]', name: '[' + cssesc(attr.name, { isIdentifier: true }) + '="' + cssesc(attr.value) + '"]',
penalty: 0.5, penalty: 0.5,
}), }),
); )
} }
function classNames(input: Element): Node[] { function classNames(input: Element): Node[] {
const names = Array.from(input.classList).filter(config.className); const names = Array.from(input.classList).filter(config.className)
return names.map( return names.map(
(name): Node => ({ (name): Node => ({
name: '.' + cssesc(name, { isIdentifier: true }), name: '.' + cssesc(name, { isIdentifier: true }),
penalty: 1, penalty: 1,
}), }),
); )
} }
function tagName(input: Element): Node | null { function tagName(input: Element): Node | null {
const name = input.tagName.toLowerCase(); const name = input.tagName.toLowerCase()
if (config.tagName(name)) { if (config.tagName(name)) {
return { return {
name, name,
penalty: 2, penalty: 2,
}; }
} }
return null; return null
} }
function any(): Node { function any(): Node {
return { return {
name: '*', name: '*',
penalty: 3, penalty: 3,
}; }
} }
function index(input: Element): number | null { function index(input: Element): number | null {
const parent = input.parentNode; const parent = input.parentNode
if (!parent) { if (!parent) {
return null; return null
} }
let child = parent.firstChild; let child = parent.firstChild
if (!child) { if (!child) {
return null; return null
} }
let i = 0; let i = 0
while (child) { while (child) {
if (child.nodeType === Node.ELEMENT_NODE) { if (child.nodeType === Node.ELEMENT_NODE) {
i++; i++
} }
if (child === input) { if (child === input) {
break; break
} }
child = child.nextSibling; child = child.nextSibling
} }
return i; return i
} }
function nthChild(node: Node, i: number): Node { function nthChild(node: Node, i: number): Node {
return { return {
name: node.name + `:nth-child(${i})`, name: node.name + `:nth-child(${i})`,
penalty: node.penalty + 1, penalty: node.penalty + 1,
}; }
} }
function dispensableNth(node: Node) { function dispensableNth(node: Node) {
return node.name !== 'html' && !node.name.startsWith('#'); return node.name !== 'html' && !node.name.startsWith('#')
} }
function maybe(...level: (Node | null)[]): Node[] | null { function maybe(...level: (Node | null)[]): Node[] | null {
const list = level.filter(notEmpty); const list = level.filter(notEmpty)
if (list.length > 0) { if (list.length > 0) {
return list; return list
} }
return null; return null
} }
function notEmpty<T>(value: T | null | undefined): value is T { function notEmpty<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined; return value !== null && value !== undefined
} }
function combinations(stack: Node[][], path: Node[] = []): Node[][] { function combinations(stack: Node[][], path: Node[] = []): Node[][] {
const paths: Node[][] = []; const paths: Node[][] = []
if (stack.length > 0) { if (stack.length > 0) {
for (const node of stack[0]) { for (const node of stack[0]) {
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node))); paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)))
} }
} else { } else {
paths.push(path); paths.push(path)
} }
return paths; return paths
} }
function sort(paths: Iterable<Path>): Path[] { function sort(paths: Iterable<Path>): Path[] {
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b)); return Array.from(paths).sort((a, b) => penalty(a) - penalty(b))
} }
type Scope = { type Scope = {
counter: number; counter: number
visited: Map<string, boolean>; visited: Map<string, boolean>
}; }
function optimize( function optimize(
path: Path, path: Path,
@ -313,103 +313,103 @@ function optimize(
visited: new Map<string, boolean>(), visited: new Map<string, boolean>(),
}, },
): Node[][] { ): Node[][] {
const paths: Node[][] = []; const paths: Node[][] = []
if (path.length > 2 && path.length > config.optimizedMinLength) { if (path.length > 2 && path.length > config.optimizedMinLength) {
for (let i = 1; i < path.length - 1; i++) { for (let i = 1; i < path.length - 1; i++) {
if (scope.counter > config.maxNumberOfTries) { if (scope.counter > config.maxNumberOfTries) {
return paths; // Okay At least I tried! return paths // Okay At least I tried!
} }
scope.counter += 1; scope.counter += 1
const newPath = [...path]; const newPath = [...path]
newPath.splice(i, 1); newPath.splice(i, 1)
const newPathKey = selector(newPath); const newPathKey = selector(newPath)
if (scope.visited.has(newPathKey)) { if (scope.visited.has(newPathKey)) {
return paths; return paths
} }
if (unique(newPath) && same(newPath, input)) { if (unique(newPath) && same(newPath, input)) {
paths.push(newPath); paths.push(newPath)
scope.visited.set(newPathKey, true); scope.visited.set(newPathKey, true)
paths.push(...optimize(newPath, input, scope)); paths.push(...optimize(newPath, input, scope))
} }
} }
} }
return paths; return paths
} }
function same(path: Path, input: Element) { function same(path: Path, input: Element) {
return rootDocument.querySelector(selector(path)) === input; return rootDocument.querySelector(selector(path)) === input
} }
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/; const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/; const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g; const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g
const defaultOptions = { const defaultOptions = {
escapeEverything: false, escapeEverything: false,
isIdentifier: false, isIdentifier: false,
quotes: 'single', quotes: 'single',
wrap: false, wrap: false,
}; }
function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) { function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
const options = { ...defaultOptions, ...opt }; const options = { ...defaultOptions, ...opt }
if (options.quotes != 'single' && options.quotes != 'double') { if (options.quotes != 'single' && options.quotes != 'double') {
options.quotes = 'single'; options.quotes = 'single'
} }
const quote = options.quotes == 'double' ? '"' : "'"; const quote = options.quotes == 'double' ? '"' : "'"
const isIdentifier = options.isIdentifier; const isIdentifier = options.isIdentifier
const firstChar = string.charAt(0); const firstChar = string.charAt(0)
let output = ''; let output = ''
let counter = 0; let counter = 0
const length = string.length; const length = string.length
while (counter < length) { while (counter < length) {
const character = string.charAt(counter++); const character = string.charAt(counter++)
let codePoint = character.charCodeAt(0); let codePoint = character.charCodeAt(0)
let value: string | undefined = void 0; let value: string | undefined = void 0
// If its not a printable ASCII character… // If its not a printable ASCII character…
if (codePoint < 0x20 || codePoint > 0x7e) { if (codePoint < 0x20 || codePoint > 0x7e) {
if (codePoint >= 0xd800 && codePoint <= 0xdbff && counter < length) { if (codePoint >= 0xd800 && codePoint <= 0xdbff && counter < length) {
// Its a high surrogate, and there is a next character. // Its a high surrogate, and there is a next character.
const extra = string.charCodeAt(counter++); const extra = string.charCodeAt(counter++)
if ((extra & 0xfc00) == 0xdc00) { if ((extra & 0xfc00) == 0xdc00) {
// next character is low surrogate // next character is low surrogate
codePoint = ((codePoint & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000; codePoint = ((codePoint & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000
} else { } else {
// Its an unmatched surrogate; only append this code unit, in case // Its an unmatched surrogate; only append this code unit, in case
// the next code unit is the high surrogate of a surrogate pair. // the next code unit is the high surrogate of a surrogate pair.
counter--; counter--
} }
} }
value = '\\' + codePoint.toString(16).toUpperCase() + ' '; value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} else { } else {
if (options.escapeEverything) { if (options.escapeEverything) {
if (regexAnySingleEscape.test(character)) { if (regexAnySingleEscape.test(character)) {
value = '\\' + character; value = '\\' + character
} else { } else {
value = '\\' + codePoint.toString(16).toUpperCase() + ' '; value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} }
} else if (/[\t\n\f\r\x0B]/.test(character)) { } else if (/[\t\n\f\r\x0B]/.test(character)) {
value = '\\' + codePoint.toString(16).toUpperCase() + ' '; value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} else if ( } else if (
character == '\\' || character == '\\' ||
(!isIdentifier && (!isIdentifier &&
((character == '"' && quote == character) || (character == "'" && quote == character))) || ((character == '"' && quote == character) || (character == "'" && quote == character))) ||
(isIdentifier && regexSingleEscape.test(character)) (isIdentifier && regexSingleEscape.test(character))
) { ) {
value = '\\' + character; value = '\\' + character
} else { } else {
value = character; value = character
} }
} }
output += value; output += value
} }
if (isIdentifier) { if (isIdentifier) {
if (/^-[-\d]/.test(output)) { if (/^-[-\d]/.test(output)) {
output = '\\-' + output.slice(1); output = '\\-' + output.slice(1)
} else if (/\d/.test(firstChar)) { } else if (/\d/.test(firstChar)) {
output = '\\3' + firstChar + ' ' + output.slice(1); output = '\\3' + firstChar + ' ' + output.slice(1)
} }
} }
@ -419,14 +419,14 @@ function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) { output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
if ($1 && $1.length % 2) { if ($1 && $1.length % 2) {
// Its not safe to remove the space, so dont. // Its not safe to remove the space, so dont.
return $0; return $0
} }
// Strip the space. // Strip the space.
return ($1 || '') + $2; return ($1 || '') + $2
}); })
if (!isIdentifier && options.wrap) { if (!isIdentifier && options.wrap) {
return quote + output + quote; return quote + output + quote
} }
return output; return output
} }

View file

@ -1,74 +1,123 @@
import type Message from '../common/messages.js'; import type Message from '../common/messages.gen.js'
import PrimitiveWriter from './PrimitiveWriter.js'; import * as Messages from '../common/messages.gen.js'
import { BatchMeta, Timestamp } from '../common/messages.js'; import MessageEncoder from './MessageEncoder.gen.js'
const SIZE_BYTES = 3
const MAX_M_SIZE = (1 << (SIZE_BYTES * 8)) - 1
export default class BatchWriter { export default class BatchWriter {
private nextIndex = 0; private nextIndex = 0
private beaconSize = 2 * 1e5; // Default 200kB private beaconSize = 2 * 1e5 // Default 200kB
private writer = new PrimitiveWriter(this.beaconSize); private encoder = new MessageEncoder(this.beaconSize)
private isEmpty = true; private readonly sizeBuffer = new Uint8Array(SIZE_BYTES)
private isEmpty = true
constructor( constructor(
private readonly pageNo: number, private readonly pageNo: number,
private timestamp: number, private timestamp: number,
private url: string,
private readonly onBatch: (batch: Uint8Array) => void, private readonly onBatch: (batch: Uint8Array) => void,
) { ) {
this.prepare(); this.prepare()
}
private writeType(m: Message): boolean {
return this.encoder.uint(m[0])
}
private writeFields(m: Message): boolean {
return this.encoder.encode(m)
}
private writeSizeAt(size: number, offset: number): void {
//boolean?
for (let i = 0; i < SIZE_BYTES; i++) {
this.sizeBuffer[i] = size >> (i * 8) // BigEndian
}
this.encoder.set(this.sizeBuffer, offset)
} }
private prepare(): void { private prepare(): void {
if (!this.writer.isEmpty()) { if (!this.encoder.isEmpty()) {
return; return
} }
new BatchMeta(this.pageNo, this.nextIndex, this.timestamp).encode(this.writer);
// MBTODO: move service-messages creation to webworker
const batchMetadata: Messages.BatchMetadata = [
Messages.Type.BatchMetadata,
1,
this.pageNo,
this.nextIndex,
this.timestamp,
this.url,
]
this.writeType(batchMetadata)
this.writeFields(batchMetadata)
this.isEmpty = true
} }
private write(message: Message): boolean { private writeWithSize(message: Message): boolean {
const wasWritten = message.encode(this.writer); const e = this.encoder
if (!this.writeType(message) || !e.skip(SIZE_BYTES)) {
// app.debug.log
return false
}
const startOffset = e.getCurrentOffset()
const wasWritten = this.writeFields(message)
if (wasWritten) { if (wasWritten) {
this.isEmpty = false; const endOffset = e.getCurrentOffset()
this.writer.checkpoint(); const size = endOffset - startOffset
this.nextIndex++; if (size > MAX_M_SIZE) {
console.warn('OpenReplay: max message size overflow.')
return false
}
this.writeSizeAt(size, startOffset - SIZE_BYTES)
e.checkpoint()
this.isEmpty = this.isEmpty && message[0] === Messages.Type.Timestamp
this.nextIndex++
} }
return wasWritten; // app.debug.log
return wasWritten
} }
private beaconSizeLimit = 1e6; private beaconSizeLimit = 1e6
setBeaconSizeLimit(limit: number) { setBeaconSizeLimit(limit: number) {
this.beaconSizeLimit = limit; this.beaconSizeLimit = limit
} }
writeMessage(message: Message) { writeMessage(message: Message) {
if (message instanceof Timestamp) { if (message[0] === Messages.Type.Timestamp) {
this.timestamp = (<any>message).timestamp; this.timestamp = message[1] // .timestamp
} }
while (!this.write(message)) { if (message[0] === Messages.Type.SetPageLocation) {
this.finaliseBatch(); this.url = message[1] // .url
}
if (this.writeWithSize(message)) {
return
}
this.finaliseBatch()
while (!this.writeWithSize(message)) {
if (this.beaconSize === this.beaconSizeLimit) { if (this.beaconSize === this.beaconSizeLimit) {
console.warn('OpenReplay: beacon size overflow. Skipping large message.'); console.warn('OpenReplay: beacon size overflow. Skipping large message.', message)
this.writer.reset(); this.encoder.reset()
this.prepare(); this.prepare()
this.isEmpty = true; return
return;
} }
// MBTODO: tempWriter for one message? // MBTODO: tempWriter for one message?
this.beaconSize = Math.min(this.beaconSize * 2, this.beaconSizeLimit); this.beaconSize = Math.min(this.beaconSize * 2, this.beaconSizeLimit)
this.writer = new PrimitiveWriter(this.beaconSize); this.encoder = new MessageEncoder(this.beaconSize)
this.prepare(); this.prepare()
this.isEmpty = true;
} }
} }
finaliseBatch() { finaliseBatch() {
if (this.isEmpty) { if (this.isEmpty) {
return; return
} }
this.onBatch(this.writer.flush()); this.onBatch(this.encoder.flush())
this.prepare(); this.prepare()
this.isEmpty = true;
} }
clean() { clean() {
this.writer.reset(); this.encoder.reset()
} }
} }

View file

@ -0,0 +1,216 @@
// Auto-generated, do not edit
import * as Messages from '../common/messages.gen.js'
import Message from '../common/messages.gen.js'
import PrimitiveEncoder from './PrimitiveEncoder.js'
export default class MessageEncoder extends PrimitiveEncoder {
encode(msg: Message): boolean {
switch(msg[0]) {
case Messages.Type.BatchMetadata:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.int(msg[4]) && this.string(msg[5])
break
case Messages.Type.PartitionedMessage:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.Timestamp:
return this.uint(msg[1])
break
case Messages.Type.SetPageLocation:
return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3])
break
case Messages.Type.SetViewportSize:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.SetViewportScroll:
return this.int(msg[1]) && this.int(msg[2])
break
case Messages.Type.CreateDocument:
return true
break
case Messages.Type.CreateElementNode:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.string(msg[4]) && this.boolean(msg[5])
break
case Messages.Type.CreateTextNode:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3])
break
case Messages.Type.MoveNode:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3])
break
case Messages.Type.RemoveNode:
return this.uint(msg[1])
break
case Messages.Type.SetNodeAttribute:
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3])
break
case Messages.Type.RemoveNodeAttribute:
return this.uint(msg[1]) && this.string(msg[2])
break
case Messages.Type.SetNodeData:
return this.uint(msg[1]) && this.string(msg[2])
break
case Messages.Type.SetNodeScroll:
return this.uint(msg[1]) && this.int(msg[2]) && this.int(msg[3])
break
case Messages.Type.SetInputTarget:
return this.uint(msg[1]) && this.string(msg[2])
break
case Messages.Type.SetInputValue:
return this.uint(msg[1]) && this.string(msg[2]) && this.int(msg[3])
break
case Messages.Type.SetInputChecked:
return this.uint(msg[1]) && this.boolean(msg[2])
break
case Messages.Type.MouseMove:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.ConsoleLog:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.PageLoadTiming:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.uint(msg[4]) && this.uint(msg[5]) && this.uint(msg[6]) && this.uint(msg[7]) && this.uint(msg[8]) && this.uint(msg[9])
break
case Messages.Type.PageRenderTiming:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3])
break
case Messages.Type.JSException:
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3])
break
case Messages.Type.RawCustomEvent:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.UserID:
return this.string(msg[1])
break
case Messages.Type.UserAnonymousID:
return this.string(msg[1])
break
case Messages.Type.Metadata:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.CSSInsertRule:
return this.uint(msg[1]) && this.string(msg[2]) && this.uint(msg[3])
break
case Messages.Type.CSSDeleteRule:
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.Fetch:
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.uint(msg[5]) && this.uint(msg[6]) && this.uint(msg[7])
break
case Messages.Type.Profiler:
return this.string(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) && this.string(msg[4])
break
case Messages.Type.OTable:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.StateAction:
return this.string(msg[1])
break
case Messages.Type.Redux:
return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3])
break
case Messages.Type.Vuex:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.MobX:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.NgRx:
return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3])
break
case Messages.Type.GraphQL:
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4])
break
case Messages.Type.PerformanceTrack:
return this.int(msg[1]) && this.int(msg[2]) && this.uint(msg[3]) && this.uint(msg[4])
break
case Messages.Type.ResourceTiming:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.uint(msg[4]) && this.uint(msg[5]) && this.uint(msg[6]) && this.string(msg[7]) && this.string(msg[8])
break
case Messages.Type.ConnectionInformation:
return this.uint(msg[1]) && this.string(msg[2])
break
case Messages.Type.SetPageVisibility:
return this.boolean(msg[1])
break
case Messages.Type.LongTask:
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3]) && this.uint(msg[4]) && this.string(msg[5]) && this.string(msg[6]) && this.string(msg[7])
break
case Messages.Type.SetNodeAttributeURLBased:
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4])
break
case Messages.Type.SetCSSDataURLBased:
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3])
break
case Messages.Type.TechnicalInfo:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.CustomIssue:
return this.string(msg[1]) && this.string(msg[2])
break
case Messages.Type.CSSInsertRuleURLBased:
return this.uint(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.string(msg[4])
break
case Messages.Type.MouseClick:
return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) && this.string(msg[4])
break
case Messages.Type.CreateIFrameDocument:
return this.uint(msg[1]) && this.uint(msg[2])
break
}
}
}

View file

@ -1,4 +1,4 @@
declare const TextEncoder: any; declare const TextEncoder: any
const textEncoder: { encode(str: string): Uint8Array } = const textEncoder: { encode(str: string): Uint8Array } =
typeof TextEncoder === 'function' typeof TextEncoder === 'function'
? new TextEncoder() ? new TextEncoder()
@ -6,102 +6,112 @@ const textEncoder: { encode(str: string): Uint8Array } =
// Based on https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder // Based on https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder
encode(str): Uint8Array { encode(str): Uint8Array {
const Len = str.length, const Len = str.length,
resArr = new Uint8Array(Len * 3); resArr = new Uint8Array(Len * 3)
let resPos = -1; let resPos = -1
for (let point = 0, nextcode = 0, i = 0; i !== Len; ) { for (let point = 0, nextcode = 0, i = 0; i !== Len; ) {
(point = str.charCodeAt(i)), (i += 1); ;(point = str.charCodeAt(i)), (i += 1)
if (point >= 0xd800 && point <= 0xdbff) { if (point >= 0xd800 && point <= 0xdbff) {
if (i === Len) { if (i === Len) {
resArr[(resPos += 1)] = 0xef; /*0b11101111*/ resArr[(resPos += 1)] = 0xef /*0b11101111*/
resArr[(resPos += 1)] = 0xbf; /*0b10111111*/ resArr[(resPos += 1)] = 0xbf /*0b10111111*/
resArr[(resPos += 1)] = 0xbd; /*0b10111101*/ resArr[(resPos += 1)] = 0xbd /*0b10111101*/
break; break
} }
// https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae // https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
nextcode = str.charCodeAt(i); nextcode = str.charCodeAt(i)
if (nextcode >= 0xdc00 && nextcode <= 0xdfff) { if (nextcode >= 0xdc00 && nextcode <= 0xdfff) {
point = (point - 0xd800) * 0x400 + nextcode - 0xdc00 + 0x10000; point = (point - 0xd800) * 0x400 + nextcode - 0xdc00 + 0x10000
i += 1; i += 1
if (point > 0xffff) { if (point > 0xffff) {
resArr[(resPos += 1)] = (0x1e /*0b11110*/ << 3) | (point >>> 18); resArr[(resPos += 1)] = (0x1e /*0b11110*/ << 3) | (point >>> 18)
resArr[(resPos += 1)] = resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f); /*0b00111111*/ (0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f) /*0b00111111*/
resArr[(resPos += 1)] = resArr[(resPos += 1)] =
(0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f); /*0b00111111*/ (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f) /*0b00111111*/
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/ resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
continue; continue
} }
} else { } else {
resArr[(resPos += 1)] = 0xef; /*0b11101111*/ resArr[(resPos += 1)] = 0xef /*0b11101111*/
resArr[(resPos += 1)] = 0xbf; /*0b10111111*/ resArr[(resPos += 1)] = 0xbf /*0b10111111*/
resArr[(resPos += 1)] = 0xbd; /*0b10111101*/ resArr[(resPos += 1)] = 0xbd /*0b10111101*/
continue; continue
} }
} }
if (point <= 0x007f) { if (point <= 0x007f) {
resArr[(resPos += 1)] = (0x0 /*0b0*/ << 7) | point; resArr[(resPos += 1)] = (0x0 /*0b0*/ << 7) | point
} else if (point <= 0x07ff) { } else if (point <= 0x07ff) {
resArr[(resPos += 1)] = (0x6 /*0b110*/ << 5) | (point >>> 6); resArr[(resPos += 1)] = (0x6 /*0b110*/ << 5) | (point >>> 6)
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/ resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
} else { } else {
resArr[(resPos += 1)] = (0xe /*0b1110*/ << 4) | (point >>> 12); resArr[(resPos += 1)] = (0xe /*0b1110*/ << 4) | (point >>> 12)
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f); /*0b00111111*/ resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f) /*0b00111111*/
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f); /*0b00111111*/ resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
} }
} }
return resArr.subarray(0, resPos + 1); return resArr.subarray(0, resPos + 1)
}, },
}; }
export default class PrimitiveWriter { export default class PrimitiveEncoder {
private offset = 0; private offset = 0
private checkpointOffset = 0; private checkpointOffset = 0
private readonly data: Uint8Array; private readonly data: Uint8Array
constructor(private readonly size: number) { constructor(private readonly size: number) {
this.data = new Uint8Array(size); this.data = new Uint8Array(size)
}
getCurrentOffset(): number {
return this.offset
} }
checkpoint() { checkpoint() {
this.checkpointOffset = this.offset; this.checkpointOffset = this.offset
} }
isEmpty(): boolean { isEmpty(): boolean {
return this.offset === 0; return this.offset === 0
}
skip(n: number): boolean {
this.offset += n
return this.offset <= this.size
}
set(bytes: Uint8Array, offset: number) {
this.data.set(bytes, offset)
} }
boolean(value: boolean): boolean { boolean(value: boolean): boolean {
this.data[this.offset++] = +value; this.data[this.offset++] = +value
return this.offset <= this.size; return this.offset <= this.size
} }
uint(value: number): boolean { uint(value: number): boolean {
if (value < 0 || value > Number.MAX_SAFE_INTEGER) { if (value < 0 || value > Number.MAX_SAFE_INTEGER) {
value = 0; value = 0
} }
while (value >= 0x80) { while (value >= 0x80) {
this.data[this.offset++] = value % 0x100 | 0x80; this.data[this.offset++] = value % 0x100 | 0x80
value = Math.floor(value / 128); value = Math.floor(value / 128)
} }
this.data[this.offset++] = value; this.data[this.offset++] = value
return this.offset <= this.size; return this.offset <= this.size
} }
int(value: number): boolean { int(value: number): boolean {
value = Math.round(value); value = Math.round(value)
return this.uint(value >= 0 ? value * 2 : value * -2 - 1); return this.uint(value >= 0 ? value * 2 : value * -2 - 1)
} }
string(value: string): boolean { string(value: string): boolean {
const encoded = textEncoder.encode(value); const encoded = textEncoder.encode(value)
const length = encoded.byteLength; const length = encoded.byteLength
if (!this.uint(length) || this.offset + length > this.size) { if (!this.uint(length) || this.offset + length > this.size) {
return false; return false
} }
this.data.set(encoded, this.offset); this.data.set(encoded, this.offset)
this.offset += length; this.offset += length
return true; return true
} }
reset(): void { reset(): void {
this.offset = 0; this.offset = 0
this.checkpointOffset = 0; this.checkpointOffset = 0
} }
flush(): Uint8Array { flush(): Uint8Array {
const data = this.data.slice(0, this.checkpointOffset); const data = this.data.slice(0, this.checkpointOffset)
this.reset(); this.reset()
return data; return data
} }
} }

View file

@ -1,6 +1,6 @@
const INGEST_PATH = '/v1/web/i'; const INGEST_PATH = '/v1/web/i'
const KEEPALIVE_SIZE_LIMIT = 64 << 10; // 64 kB const KEEPALIVE_SIZE_LIMIT = 64 << 10 // 64 kB
// function sendXHR(url: string, token: string, batch: Uint8Array): Promise<XMLHttpRequest> { // function sendXHR(url: string, token: string, batch: Uint8Array): Promise<XMLHttpRequest> {
// const req = new XMLHttpRequest() // const req = new XMLHttpRequest()
@ -21,11 +21,11 @@ const KEEPALIVE_SIZE_LIMIT = 64 << 10; // 64 kB
// } // }
export default class QueueSender { export default class QueueSender {
private attemptsCount = 0; private attemptsCount = 0
private busy = false; private busy = false
private readonly queue: Array<Uint8Array> = []; private readonly queue: Array<Uint8Array> = []
private readonly ingestURL; private readonly ingestURL
private token: string | null = null; private token: string | null = null
constructor( constructor(
ingestBaseURL: string, ingestBaseURL: string,
private readonly onUnauthorised: () => any, private readonly onUnauthorised: () => any,
@ -33,33 +33,33 @@ export default class QueueSender {
private readonly MAX_ATTEMPTS_COUNT = 10, private readonly MAX_ATTEMPTS_COUNT = 10,
private readonly ATTEMPT_TIMEOUT = 1000, private readonly ATTEMPT_TIMEOUT = 1000,
) { ) {
this.ingestURL = ingestBaseURL + INGEST_PATH; this.ingestURL = ingestBaseURL + INGEST_PATH
} }
authorise(token: string): void { authorise(token: string): void {
this.token = token; this.token = token
} }
push(batch: Uint8Array): void { push(batch: Uint8Array): void {
if (this.busy || !this.token) { if (this.busy || !this.token) {
this.queue.push(batch); this.queue.push(batch)
} else { } else {
this.sendBatch(batch); this.sendBatch(batch)
} }
} }
private retry(batch: Uint8Array): void { private retry(batch: Uint8Array): void {
if (this.attemptsCount >= this.MAX_ATTEMPTS_COUNT) { if (this.attemptsCount >= this.MAX_ATTEMPTS_COUNT) {
this.onFailure(); this.onFailure()
return; return
} }
this.attemptsCount++; this.attemptsCount++
setTimeout(() => this.sendBatch(batch), this.ATTEMPT_TIMEOUT * this.attemptsCount); setTimeout(() => this.sendBatch(batch), this.ATTEMPT_TIMEOUT * this.attemptsCount)
} }
// would be nice to use Beacon API, but it is not available in WebWorker // would be nice to use Beacon API, but it is not available in WebWorker
private sendBatch(batch: Uint8Array): void { private sendBatch(batch: Uint8Array): void {
this.busy = true; this.busy = true
fetch(this.ingestURL, { fetch(this.ingestURL, {
body: batch, body: batch,
@ -73,30 +73,30 @@ export default class QueueSender {
.then((r) => { .then((r) => {
if (r.status === 401) { if (r.status === 401) {
// TODO: continuous session ? // TODO: continuous session ?
this.busy = false; this.busy = false
this.onUnauthorised(); this.onUnauthorised()
return; return
} else if (r.status >= 400) { } else if (r.status >= 400) {
this.retry(batch); this.retry(batch)
return; return
} }
// Success // Success
this.attemptsCount = 0; this.attemptsCount = 0
const nextBatch = this.queue.shift(); const nextBatch = this.queue.shift()
if (nextBatch) { if (nextBatch) {
this.sendBatch(nextBatch); this.sendBatch(nextBatch)
} else { } else {
this.busy = false; this.busy = false
} }
}) })
.catch((e) => { .catch((e) => {
console.warn('OpenReplay:', e); console.warn('OpenReplay:', e)
this.retry(batch); this.retry(batch)
}); })
} }
clean() { clean() {
this.queue.length = 0; this.queue.length = 0
} }
} }

View file

@ -1,9 +1,9 @@
import type Message from '../common/messages.js'; import type Message from '../common/messages.gen.js'
import { WorkerMessageData } from '../common/webworker.js'; import { Type as MType } from '../common/messages.gen.js'
import { WorkerMessageData } from '../common/interaction.js'
import { classes, SetPageVisibility } from '../common/messages.js'; import QueueSender from './QueueSender.js'
import QueueSender from './QueueSender.js'; import BatchWriter from './BatchWriter.js'
import BatchWriter from './BatchWriter.js';
enum WorkerStatus { enum WorkerStatus {
NotActive, NotActive,
@ -12,114 +12,112 @@ enum WorkerStatus {
Active, Active,
} }
const AUTO_SEND_INTERVAL = 10 * 1000; const AUTO_SEND_INTERVAL = 10 * 1000
let sender: QueueSender | null = null; let sender: QueueSender | null = null
let writer: BatchWriter | null = null; let writer: BatchWriter | null = null
let workerStatus: WorkerStatus = WorkerStatus.NotActive; let workerStatus: WorkerStatus = WorkerStatus.NotActive
function send(): void { function send(): void {
if (!writer) { if (!writer) {
return; return
} }
writer.finaliseBatch(); writer.finaliseBatch()
} }
function reset(): void { function reset(): void {
workerStatus = WorkerStatus.Stopping; workerStatus = WorkerStatus.Stopping
if (sendIntervalID !== null) { if (sendIntervalID !== null) {
clearInterval(sendIntervalID); clearInterval(sendIntervalID)
sendIntervalID = null; sendIntervalID = null
} }
if (writer) { if (writer) {
writer.clean(); writer.clean()
writer = null; writer = null
} }
workerStatus = WorkerStatus.NotActive; workerStatus = WorkerStatus.NotActive
} }
function resetCleanQueue(): void { function resetCleanQueue(): void {
if (sender) { if (sender) {
sender.clean(); sender.clean()
sender = null; sender = null
} }
reset(); reset()
} }
let sendIntervalID: ReturnType<typeof setInterval> | null = null; let sendIntervalID: ReturnType<typeof setInterval> | null = null
let restartTimeoutID: ReturnType<typeof setTimeout>; let restartTimeoutID: ReturnType<typeof setTimeout>
self.onmessage = ({ data }: MessageEvent<WorkerMessageData>): any => { self.onmessage = ({ data }: MessageEvent<WorkerMessageData>): any => {
if (data == null) { if (data == null) {
send(); // TODO: sendAll? send() // TODO: sendAll?
return; return
} }
if (data === 'stop') { if (data === 'stop') {
send(); send()
reset(); reset()
return; return
} }
if (Array.isArray(data)) { if (Array.isArray(data)) {
if (!writer) {
throw new Error('WebWorker: writer not initialised. Service Should be Started.');
}
const w = writer;
// Message[] // Message[]
data.forEach((data) => { if (!writer) {
// @ts-ignore throw new Error('WebWorker: writer not initialised. Service Should be Started.')
const message: Message = new (classes.get(data._id))(); }
Object.assign(message, data); const w = writer
if (message instanceof SetPageVisibility) { data.forEach((message) => {
// @ts-ignore if (message[0] === MType.SetPageVisibility) {
if ((<any>message).hidden) { if (message[1]) {
restartTimeoutID = setTimeout(() => self.postMessage('restart'), 30 * 60 * 1000); // .hidden
restartTimeoutID = setTimeout(() => self.postMessage('restart'), 30 * 60 * 1000)
} else { } else {
clearTimeout(restartTimeoutID); clearTimeout(restartTimeoutID)
} }
} }
w.writeMessage(message); w.writeMessage(message)
}); })
return; return
} }
if (data.type === 'start') { if (data.type === 'start') {
workerStatus = WorkerStatus.Starting; workerStatus = WorkerStatus.Starting
sender = new QueueSender( sender = new QueueSender(
data.ingestPoint, data.ingestPoint,
() => { () => {
// onUnauthorised // onUnauthorised
self.postMessage('restart'); self.postMessage('restart')
}, },
() => { () => {
// onFailure // onFailure
resetCleanQueue(); resetCleanQueue()
self.postMessage('failed'); self.postMessage('failed')
}, },
data.connAttemptCount, data.connAttemptCount,
data.connAttemptGap, data.connAttemptGap,
); )
writer = new BatchWriter( writer = new BatchWriter(
data.pageNo, data.pageNo,
data.timestamp, data.timestamp,
data.url,
// onBatch // onBatch
(batch) => sender && sender.push(batch), (batch) => sender && sender.push(batch),
); )
if (sendIntervalID === null) { if (sendIntervalID === null) {
sendIntervalID = setInterval(send, AUTO_SEND_INTERVAL); sendIntervalID = setInterval(send, AUTO_SEND_INTERVAL)
} }
return (workerStatus = WorkerStatus.Active); return (workerStatus = WorkerStatus.Active)
} }
if (data.type === 'auth') { if (data.type === 'auth') {
if (!sender) { if (!sender) {
throw new Error('WebWorker: sender not initialised. Received auth.'); throw new Error('WebWorker: sender not initialised. Received auth.')
} }
if (!writer) { if (!writer) {
throw new Error('WebWorker: writer not initialised. Received auth.'); throw new Error('WebWorker: writer not initialised. Received auth.')
} }
sender.authorise(data.token); sender.authorise(data.token)
data.beaconSizeLimit && writer.setBeaconSizeLimit(data.beaconSizeLimit); data.beaconSizeLimit && writer.setBeaconSizeLimit(data.beaconSizeLimit)
return; return
} }
}; }