Compare commits
2 commits
main
...
workerless
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35d0d06f2 | ||
|
|
13ae208462 |
12 changed files with 1287 additions and 61 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "16.0.1",
|
||||
"version": "16.0.1-2",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const require = createRequire(import.meta.url)
|
|||
const packageConfig = require('./package.json')
|
||||
|
||||
export default async () => {
|
||||
const webworkerContent = await buildWebWorker()
|
||||
// const webworkerContent = await buildWebWorker()
|
||||
|
||||
const commonPlugins = [
|
||||
resolve(),
|
||||
|
|
@ -17,7 +17,7 @@ export default async () => {
|
|||
preventAssignment: true,
|
||||
values: {
|
||||
TRACKER_VERSION: packageConfig.version,
|
||||
'global.WEBWORKER_BODY': JSON.stringify(webworkerContent),
|
||||
// 'global.WEBWORKER_BODY': JSON.stringify(webworkerContent),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
139
tracker/tracker/src/NetworkWorker/BatchWriter.ts
Normal file
139
tracker/tracker/src/NetworkWorker/BatchWriter.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type Message from '../common/messages.gen.js'
|
||||
import * as Messages from '../common/messages.gen.js'
|
||||
import MessageEncoder from './MessageEncoder.gen.js'
|
||||
import { requestIdleCb } from '../main/utils.js';
|
||||
|
||||
const SIZE_BYTES = 3
|
||||
const MAX_M_SIZE = (1 << (SIZE_BYTES * 8)) - 1
|
||||
|
||||
export default class BatchWriter {
|
||||
private nextIndex = 0
|
||||
private beaconSize = 2 * 1e5 // Default 200kB
|
||||
private encoder = new MessageEncoder(this.beaconSize)
|
||||
private readonly sizeBuffer = new Uint8Array(SIZE_BYTES)
|
||||
private isEmpty = true
|
||||
|
||||
constructor(
|
||||
private readonly pageNo: number,
|
||||
private timestamp: number,
|
||||
private url: string,
|
||||
private readonly onBatch: (batch: Uint8Array) => void,
|
||||
private tabId: string,
|
||||
private readonly onOfflineEnd: () => void,
|
||||
) {
|
||||
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 {
|
||||
if (!this.encoder.isEmpty) {
|
||||
return
|
||||
}
|
||||
|
||||
// MBTODO: move service-messages creation methods to webworker
|
||||
const batchMetadata: Messages.BatchMetadata = [
|
||||
Messages.Type.BatchMetadata,
|
||||
1,
|
||||
this.pageNo,
|
||||
this.nextIndex,
|
||||
this.timestamp,
|
||||
this.url,
|
||||
]
|
||||
|
||||
const tabData: Messages.TabData = [Messages.Type.TabData, this.tabId]
|
||||
|
||||
this.writeType(batchMetadata)
|
||||
this.writeFields(batchMetadata)
|
||||
this.writeWithSize(tabData as Message)
|
||||
this.isEmpty = true
|
||||
}
|
||||
|
||||
private writeWithSize(message: Message): boolean {
|
||||
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) {
|
||||
const endOffset = e.getCurrentOffset()
|
||||
const size = endOffset - startOffset
|
||||
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++
|
||||
}
|
||||
// app.debug.log
|
||||
return wasWritten
|
||||
}
|
||||
|
||||
private beaconSizeLimit = 1e6
|
||||
setBeaconSizeLimit(limit: number) {
|
||||
this.beaconSizeLimit = limit
|
||||
}
|
||||
|
||||
writeMessage(message: Message) {
|
||||
// @ts-ignore
|
||||
if (message[0] === 'q_end') {
|
||||
this.finaliseBatch()
|
||||
return this.onOfflineEnd()
|
||||
}
|
||||
if (message[0] === Messages.Type.Timestamp) {
|
||||
this.timestamp = message[1] // .timestamp
|
||||
}
|
||||
if (message[0] === Messages.Type.SetPageLocation) {
|
||||
this.url = message[1] // .url
|
||||
}
|
||||
if (this.writeWithSize(message)) {
|
||||
return
|
||||
}
|
||||
// buffer overflow, send already written data first then try again
|
||||
this.finaliseBatch()
|
||||
if (this.writeWithSize(message)) {
|
||||
return
|
||||
}
|
||||
// buffer is too small. Creating one with maximal capacity for this message only
|
||||
this.encoder = new MessageEncoder(this.beaconSizeLimit)
|
||||
this.prepare()
|
||||
if (!this.writeWithSize(message)) {
|
||||
console.warn('OpenReplay: beacon size overflow. Skipping large message.', message, this)
|
||||
} else {
|
||||
this.finaliseBatch()
|
||||
}
|
||||
// reset encoder to normal size
|
||||
this.encoder = new MessageEncoder(this.beaconSize)
|
||||
this.prepare()
|
||||
}
|
||||
|
||||
finaliseBatch() {
|
||||
if (this.isEmpty) {
|
||||
return
|
||||
}
|
||||
const batch = this.encoder.flush()
|
||||
this.onBatch(batch)
|
||||
this.prepare()
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.encoder.reset()
|
||||
}
|
||||
}
|
||||
90
tracker/tracker/src/NetworkWorker/BatchWriter.unit.test.ts
Normal file
90
tracker/tracker/src/NetworkWorker/BatchWriter.unit.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import BatchWriter from './BatchWriter'
|
||||
import * as Messages from '../common/messages.gen.js'
|
||||
import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals'
|
||||
import Message from '../common/messages.gen.js'
|
||||
|
||||
describe('BatchWriter', () => {
|
||||
let onBatchMock: (b: Uint8Array) => void
|
||||
let batchWriter: BatchWriter
|
||||
|
||||
beforeEach(() => {
|
||||
onBatchMock = jest.fn()
|
||||
batchWriter = new BatchWriter(1, 123456789, 'example.com', onBatchMock, '123', () => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('constructor initializes BatchWriter instance', () => {
|
||||
expect(batchWriter['pageNo']).toBe(1)
|
||||
expect(batchWriter['timestamp']).toBe(123456789)
|
||||
expect(batchWriter['url']).toBe('example.com')
|
||||
expect(batchWriter['onBatch']).toBe(onBatchMock)
|
||||
// we add tab id as first in the batch
|
||||
expect(batchWriter['nextIndex']).toBe(1)
|
||||
expect(batchWriter['beaconSize']).toBe(200000)
|
||||
expect(batchWriter['encoder']).toBeDefined()
|
||||
expect(batchWriter['sizeBuffer']).toHaveLength(3)
|
||||
expect(batchWriter['isEmpty']).toBe(true)
|
||||
})
|
||||
|
||||
test('writeType writes the type of the message', () => {
|
||||
// @ts-ignore
|
||||
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
|
||||
const result = batchWriter['writeType'](message as Message)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('writeFields encodes the message fields', () => {
|
||||
// @ts-ignore
|
||||
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
|
||||
const result = batchWriter['writeFields'](message as Message)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('writeSizeAt writes the size at the given offset', () => {
|
||||
batchWriter['writeSizeAt'](100, 0)
|
||||
expect(batchWriter['sizeBuffer']).toEqual(new Uint8Array([100, 0, 0]))
|
||||
expect(batchWriter['encoder']['data'].slice(0, 3)).toEqual(new Uint8Array([100, 0, 0]))
|
||||
})
|
||||
|
||||
test('prepare prepares the BatchWriter for writing', () => {
|
||||
// TODO
|
||||
})
|
||||
|
||||
test('writeWithSize writes the message with its size', () => {
|
||||
// @ts-ignore
|
||||
const message = [Messages.Type.BatchMetadata, 1, 2, 3, 4, 'example.com']
|
||||
const result = batchWriter['writeWithSize'](message as Message)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('setBeaconSizeLimit sets the beacon size limit', () => {
|
||||
batchWriter['setBeaconSizeLimit'](500000)
|
||||
expect(batchWriter['beaconSizeLimit']).toBe(500000)
|
||||
})
|
||||
|
||||
test('writeMessage writes the given message', () => {
|
||||
// @ts-ignore
|
||||
const message = [Messages.Type.Timestamp, 987654321]
|
||||
// @ts-ignore
|
||||
batchWriter['writeWithSize'] = jest.fn().mockReturnValue(true)
|
||||
batchWriter['writeMessage'](message as Message)
|
||||
expect(batchWriter['writeWithSize']).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
test('finaliseBatch flushes the encoder and calls onBatch', () => {
|
||||
const flushSpy = jest.spyOn(batchWriter['encoder'], 'flush')
|
||||
batchWriter['isEmpty'] = false
|
||||
batchWriter['finaliseBatch']()
|
||||
expect(flushSpy).toHaveBeenCalled()
|
||||
expect(onBatchMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clean resets the encoder', () => {
|
||||
const cleanSpy = jest.spyOn(batchWriter['encoder'], 'reset')
|
||||
batchWriter['clean']()
|
||||
expect(cleanSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
340
tracker/tracker/src/NetworkWorker/MessageEncoder.gen.ts
Normal file
340
tracker/tracker/src/NetworkWorker/MessageEncoder.gen.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
// Auto-generated, do not edit
|
||||
/* eslint-disable */
|
||||
|
||||
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.Timestamp:
|
||||
return this.uint(msg[1])
|
||||
break
|
||||
|
||||
case Messages.Type.SetPageLocationDeprecated:
|
||||
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.NetworkRequestDeprecated:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.string(msg[5]) && this.uint(msg[6]) && this.uint(msg[7]) && this.uint(msg[8])
|
||||
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.CustomEvent:
|
||||
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.StringDictGlobal:
|
||||
return this.uint(msg[1]) && this.string(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.SetNodeAttributeDictGlobal:
|
||||
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3])
|
||||
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.ReduxDeprecated:
|
||||
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.GraphQLDeprecated:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.int(msg[5])
|
||||
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.StringDictDeprecated:
|
||||
return this.uint(msg[1]) && this.string(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.SetNodeAttributeDictDeprecated:
|
||||
return this.uint(msg[1]) && this.uint(msg[2]) && this.uint(msg[3])
|
||||
break
|
||||
|
||||
case Messages.Type.StringDict:
|
||||
return this.string(msg[1]) && this.string(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.SetNodeAttributeDict:
|
||||
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3])
|
||||
break
|
||||
|
||||
case Messages.Type.ResourceTimingDeprecated:
|
||||
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.LoadFontFace:
|
||||
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.SetNodeFocus:
|
||||
return this.int(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]) && this.uint(msg[5]) && this.uint(msg[6])
|
||||
break
|
||||
|
||||
case Messages.Type.MouseClickDeprecated:
|
||||
return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3]) && this.string(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.CreateIFrameDocument:
|
||||
return this.uint(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.AdoptedSSReplaceURLBased:
|
||||
return this.uint(msg[1]) && this.string(msg[2]) && this.string(msg[3])
|
||||
break
|
||||
|
||||
case Messages.Type.AdoptedSSInsertRuleURLBased:
|
||||
return this.uint(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.string(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.AdoptedSSDeleteRule:
|
||||
return this.uint(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.AdoptedSSAddOwner:
|
||||
return this.uint(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.AdoptedSSRemoveOwner:
|
||||
return this.uint(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.JSException:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.Zustand:
|
||||
return this.string(msg[1]) && this.string(msg[2])
|
||||
break
|
||||
|
||||
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.NetworkRequest:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.string(msg[5]) && this.uint(msg[6]) && this.uint(msg[7]) && this.uint(msg[8]) && this.uint(msg[9])
|
||||
break
|
||||
|
||||
case Messages.Type.WSChannel:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.uint(msg[4]) && this.string(msg[5]) && this.string(msg[6])
|
||||
break
|
||||
|
||||
case Messages.Type.InputChange:
|
||||
return this.uint(msg[1]) && this.string(msg[2]) && this.boolean(msg[3]) && this.string(msg[4]) && this.int(msg[5]) && this.int(msg[6])
|
||||
break
|
||||
|
||||
case Messages.Type.SelectionChange:
|
||||
return this.uint(msg[1]) && this.uint(msg[2]) && this.string(msg[3])
|
||||
break
|
||||
|
||||
case Messages.Type.MouseThrashing:
|
||||
return this.uint(msg[1])
|
||||
break
|
||||
|
||||
case Messages.Type.UnbindNodes:
|
||||
return this.uint(msg[1])
|
||||
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]) && this.uint(msg[9]) && this.boolean(msg[10])
|
||||
break
|
||||
|
||||
case Messages.Type.TabChange:
|
||||
return this.string(msg[1])
|
||||
break
|
||||
|
||||
case Messages.Type.TabData:
|
||||
return this.string(msg[1])
|
||||
break
|
||||
|
||||
case Messages.Type.CanvasNode:
|
||||
return this.string(msg[1]) && this.uint(msg[2])
|
||||
break
|
||||
|
||||
case Messages.Type.TagTrigger:
|
||||
return this.int(msg[1])
|
||||
break
|
||||
|
||||
case Messages.Type.Redux:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.uint(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.SetPageLocation:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.uint(msg[3]) && this.string(msg[4])
|
||||
break
|
||||
|
||||
case Messages.Type.GraphQL:
|
||||
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.uint(msg[5])
|
||||
break
|
||||
|
||||
case Messages.Type.WebVitals:
|
||||
return this.string(msg[1]) && this.string(msg[2])
|
||||
break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
117
tracker/tracker/src/NetworkWorker/PrimitiveEncoder.ts
Normal file
117
tracker/tracker/src/NetworkWorker/PrimitiveEncoder.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
declare const TextEncoder: any
|
||||
const textEncoder: { encode(str: string): Uint8Array } =
|
||||
typeof TextEncoder === 'function'
|
||||
? new TextEncoder()
|
||||
: {
|
||||
// Based on https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder
|
||||
encode(str): Uint8Array {
|
||||
const Len = str.length,
|
||||
resArr = new Uint8Array(Len * 3)
|
||||
let resPos = -1
|
||||
for (let point = 0, nextcode = 0, i = 0; i !== Len; ) {
|
||||
;(point = str.charCodeAt(i)), (i += 1)
|
||||
if (point >= 0xd800 && point <= 0xdbff) {
|
||||
if (i === Len) {
|
||||
resArr[(resPos += 1)] = 0xef /*0b11101111*/
|
||||
resArr[(resPos += 1)] = 0xbf /*0b10111111*/
|
||||
resArr[(resPos += 1)] = 0xbd /*0b10111101*/
|
||||
break
|
||||
}
|
||||
// https://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
|
||||
nextcode = str.charCodeAt(i)
|
||||
if (nextcode >= 0xdc00 && nextcode <= 0xdfff) {
|
||||
point = (point - 0xd800) * 0x400 + nextcode - 0xdc00 + 0x10000
|
||||
i += 1
|
||||
if (point > 0xffff) {
|
||||
resArr[(resPos += 1)] = (0x1e /*0b11110*/ << 3) | (point >>> 18)
|
||||
resArr[(resPos += 1)] =
|
||||
(0x2 /*0b10*/ << 6) | ((point >>> 12) & 0x3f) /*0b00111111*/
|
||||
resArr[(resPos += 1)] =
|
||||
(0x2 /*0b10*/ << 6) | ((point >>> 6) & 0x3f) /*0b00111111*/
|
||||
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
resArr[(resPos += 1)] = 0xef /*0b11101111*/
|
||||
resArr[(resPos += 1)] = 0xbf /*0b10111111*/
|
||||
resArr[(resPos += 1)] = 0xbd /*0b10111101*/
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (point <= 0x007f) {
|
||||
resArr[(resPos += 1)] = (0x0 /*0b0*/ << 7) | point
|
||||
} else if (point <= 0x07ff) {
|
||||
resArr[(resPos += 1)] = (0x6 /*0b110*/ << 5) | (point >>> 6)
|
||||
resArr[(resPos += 1)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
|
||||
} else {
|
||||
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 & 0x3f) /*0b00111111*/
|
||||
}
|
||||
}
|
||||
return resArr.subarray(0, resPos + 1)
|
||||
},
|
||||
}
|
||||
|
||||
export default class PrimitiveEncoder {
|
||||
private offset = 0
|
||||
private checkpointOffset = 0
|
||||
private readonly data: Uint8Array
|
||||
constructor(private readonly size: number) {
|
||||
this.data = new Uint8Array(size)
|
||||
}
|
||||
getCurrentOffset(): number {
|
||||
return this.offset
|
||||
}
|
||||
checkpoint() {
|
||||
this.checkpointOffset = this.offset
|
||||
}
|
||||
get isEmpty(): boolean {
|
||||
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 {
|
||||
this.data[this.offset++] = +value
|
||||
return this.offset <= this.size
|
||||
}
|
||||
uint(value: number): boolean {
|
||||
if (value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
||||
value = 0
|
||||
}
|
||||
while (value >= 0x80) {
|
||||
this.data[this.offset++] = value % 0x100 | 0x80
|
||||
value = Math.floor(value / 128)
|
||||
}
|
||||
this.data[this.offset++] = value
|
||||
return this.offset <= this.size
|
||||
}
|
||||
int(value: number): boolean {
|
||||
value = Math.round(value)
|
||||
return this.uint(value >= 0 ? value * 2 : value * -2 - 1)
|
||||
}
|
||||
string(value: string): boolean {
|
||||
const encoded = textEncoder.encode(value)
|
||||
const length = encoded.byteLength
|
||||
if (!this.uint(length) || this.offset + length > this.size) {
|
||||
return false
|
||||
}
|
||||
this.data.set(encoded, this.offset)
|
||||
this.offset += length
|
||||
return true
|
||||
}
|
||||
reset(): void {
|
||||
this.offset = 0
|
||||
this.checkpointOffset = 0
|
||||
}
|
||||
flush(): Uint8Array {
|
||||
const data = this.data.slice(0, this.checkpointOffset)
|
||||
this.reset()
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals'
|
||||
import PrimitiveEncoder from './PrimitiveEncoder.js'
|
||||
|
||||
describe('PrimitiveEncoder', () => {
|
||||
test('initial state', () => {
|
||||
const enc = new PrimitiveEncoder(10)
|
||||
|
||||
expect(enc.getCurrentOffset()).toBe(0)
|
||||
expect(enc.isEmpty).toBe(true)
|
||||
expect(enc.flush().length).toBe(0)
|
||||
})
|
||||
|
||||
test('skip()', () => {
|
||||
const enc = new PrimitiveEncoder(10)
|
||||
enc.skip(5)
|
||||
expect(enc.getCurrentOffset()).toBe(5)
|
||||
expect(enc.isEmpty).toBe(false)
|
||||
})
|
||||
|
||||
test('checkpoint()', () => {
|
||||
const enc = new PrimitiveEncoder(10)
|
||||
enc.skip(5)
|
||||
enc.checkpoint()
|
||||
expect(enc.flush().length).toBe(5)
|
||||
})
|
||||
|
||||
test('boolean(true)', () => {
|
||||
const enc = new PrimitiveEncoder(10)
|
||||
enc.boolean(true)
|
||||
enc.checkpoint()
|
||||
const bytes = enc.flush()
|
||||
expect(bytes.length).toBe(1)
|
||||
expect(bytes[0]).toBe(1)
|
||||
})
|
||||
test('boolean(false)', () => {
|
||||
const enc = new PrimitiveEncoder(10)
|
||||
enc.boolean(false)
|
||||
enc.checkpoint()
|
||||
const bytes = enc.flush()
|
||||
expect(bytes.length).toBe(1)
|
||||
expect(bytes[0]).toBe(0)
|
||||
})
|
||||
// TODO: test correct enc/dec on a top level(?) with player(PrimitiveReader.ts)/tracker(PrimitiveEncoder.ts)
|
||||
|
||||
test('buffer oveflow with string()', () => {
|
||||
const N = 10
|
||||
const enc = new PrimitiveEncoder(N)
|
||||
const wasWritten = enc.string('long string'.repeat(N))
|
||||
expect(wasWritten).toBe(false)
|
||||
})
|
||||
test('buffer oveflow with uint()', () => {
|
||||
const enc = new PrimitiveEncoder(1)
|
||||
const wasWritten = enc.uint(Number.MAX_SAFE_INTEGER)
|
||||
expect(wasWritten).toBe(false)
|
||||
})
|
||||
test('buffer oveflow with boolean()', () => {
|
||||
const enc = new PrimitiveEncoder(1)
|
||||
let wasWritten = enc.boolean(true)
|
||||
expect(wasWritten).toBe(true)
|
||||
wasWritten = enc.boolean(true)
|
||||
expect(wasWritten).toBe(false)
|
||||
})
|
||||
})
|
||||
154
tracker/tracker/src/NetworkWorker/QueueSender.ts
Normal file
154
tracker/tracker/src/NetworkWorker/QueueSender.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
const INGEST_PATH = '/v1/web/i'
|
||||
const KEEPALIVE_SIZE_LIMIT = 64 << 10 // 64 kB
|
||||
|
||||
export default class QueueSender {
|
||||
private attemptsCount = 0
|
||||
private busy = false
|
||||
private readonly queue: Array<Uint8Array> = []
|
||||
private readonly ingestURL
|
||||
private token: string | null = null
|
||||
// its actually on #24
|
||||
// eslint-disable-next-line
|
||||
private isCompressing
|
||||
private lastBatchNum = 0
|
||||
|
||||
constructor(
|
||||
ingestBaseURL: string,
|
||||
private readonly onUnauthorised: () => any,
|
||||
private readonly onFailure: (reason: string) => any,
|
||||
private readonly MAX_ATTEMPTS_COUNT = 10,
|
||||
private readonly ATTEMPT_TIMEOUT = 250,
|
||||
private readonly onCompress?: (batch: Uint8Array) => any,
|
||||
private readonly pageNo?: number,
|
||||
) {
|
||||
this.ingestURL = ingestBaseURL + INGEST_PATH
|
||||
if (onCompress !== undefined) {
|
||||
this.isCompressing = true
|
||||
} else {
|
||||
this.isCompressing = false
|
||||
}
|
||||
}
|
||||
|
||||
public getQueueStatus() {
|
||||
return this.queue.length === 0 && !this.busy
|
||||
}
|
||||
|
||||
authorise(token: string): void {
|
||||
this.token = token
|
||||
if (!this.busy) {
|
||||
// TODO: transparent busy/send logic
|
||||
this.sendNext()
|
||||
}
|
||||
}
|
||||
|
||||
push(batch: Uint8Array): void {
|
||||
if (this.busy || !this.token) {
|
||||
this.queue.push(batch)
|
||||
} else {
|
||||
this.busy = true
|
||||
if (this.isCompressing && this.onCompress) {
|
||||
this.onCompress(batch)
|
||||
} else {
|
||||
const batchNum = ++this.lastBatchNum
|
||||
this.sendBatch(batch, false, batchNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendNext() {
|
||||
const nextBatch = this.queue.shift()
|
||||
if (nextBatch) {
|
||||
this.busy = true
|
||||
if (this.isCompressing && this.onCompress) {
|
||||
this.onCompress(nextBatch)
|
||||
} else {
|
||||
const batchNum = ++this.lastBatchNum
|
||||
this.sendBatch(nextBatch, false, batchNum)
|
||||
}
|
||||
} else {
|
||||
this.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
private retry(batch: Uint8Array, isCompressed?: boolean, batchNum?: string | number): void {
|
||||
if (this.attemptsCount >= this.MAX_ATTEMPTS_COUNT) {
|
||||
this.onFailure(`Failed to send batch after ${this.attemptsCount} attempts.`)
|
||||
// remains this.busy === true
|
||||
return
|
||||
}
|
||||
this.attemptsCount++
|
||||
setTimeout(
|
||||
() => this.sendBatch(batch, isCompressed, batchNum),
|
||||
this.ATTEMPT_TIMEOUT * this.attemptsCount,
|
||||
)
|
||||
}
|
||||
|
||||
// would be nice to use Beacon API, but it is not available in WebWorker
|
||||
private sendBatch(batch: Uint8Array, isCompressed?: boolean, batchNum?: string | number): void {
|
||||
const batchNumStr = batchNum?.toString().replace(/^([^_]+)_([^_]+).*/, '$1_$2_$3')
|
||||
this.busy = true
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${this.token as string}`,
|
||||
} as Record<string, string>
|
||||
|
||||
if (isCompressed) {
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
}
|
||||
|
||||
/**
|
||||
* sometimes happen during assist connects for some reason
|
||||
* */
|
||||
if (this.token === null) {
|
||||
setTimeout(() => {
|
||||
this.sendBatch(batch, isCompressed, `${batchNum ?? 'noBatchNum'}_newToken`)
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${this.ingestURL}?batch=${this.pageNo ?? 'noPageNum'}_${batchNumStr ?? 'noBatchNum'}`, {
|
||||
body: batch,
|
||||
method: 'POST',
|
||||
headers,
|
||||
keepalive: batch.length < KEEPALIVE_SIZE_LIMIT,
|
||||
})
|
||||
.then((r: Record<string, any>) => {
|
||||
if (r.status === 401) {
|
||||
// TODO: continuous session ?
|
||||
this.busy = false
|
||||
this.onUnauthorised()
|
||||
return
|
||||
} else if (r.status >= 400) {
|
||||
this.retry(batch, isCompressed, `${batchNum ?? 'noBatchNum'}_network:${r.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
this.attemptsCount = 0
|
||||
this.sendNext()
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.warn('OpenReplay:', e)
|
||||
this.retry(batch, isCompressed, `${batchNum ?? 'noBatchNum'}_reject:${e.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
sendCompressed(batch: Uint8Array) {
|
||||
const batchNum = ++this.lastBatchNum
|
||||
this.sendBatch(batch, true, batchNum)
|
||||
}
|
||||
|
||||
sendUncompressed(batch: Uint8Array) {
|
||||
const batchNum = ++this.lastBatchNum
|
||||
this.sendBatch(batch, false, batchNum)
|
||||
}
|
||||
|
||||
clean() {
|
||||
// sending last batch and closing the shop
|
||||
this.sendNext()
|
||||
setTimeout(() => {
|
||||
this.token = null
|
||||
this.queue.length = 0
|
||||
}, 10)
|
||||
}
|
||||
}
|
||||
132
tracker/tracker/src/NetworkWorker/QueueSender.unit.test.ts
Normal file
132
tracker/tracker/src/NetworkWorker/QueueSender.unit.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, expect, test, jest, beforeEach, afterEach } from '@jest/globals'
|
||||
import QueueSender from './QueueSender.js'
|
||||
|
||||
global.fetch = () => Promise.resolve(new Response()) // jsdom does not have it
|
||||
|
||||
function mockFetch(status: number, headers?: Record<string, string>) {
|
||||
return jest.spyOn(global, 'fetch').mockImplementation((request) =>
|
||||
Promise.resolve({ status, headers, request } as unknown as Response & {
|
||||
request: RequestInfo
|
||||
}),
|
||||
)
|
||||
}
|
||||
const baseURL = 'MYBASEURL'
|
||||
const sampleArray = new Uint8Array(1)
|
||||
const randomToken = 'abc'
|
||||
|
||||
const requestMock = {
|
||||
body: sampleArray,
|
||||
headers: { Authorization: 'Bearer abc' },
|
||||
keepalive: true,
|
||||
method: 'POST',
|
||||
}
|
||||
|
||||
const gzipRequestMock = {
|
||||
...requestMock,
|
||||
headers: { ...requestMock.headers, 'Content-Encoding': 'gzip' },
|
||||
}
|
||||
|
||||
function defaultQueueSender({
|
||||
url = baseURL,
|
||||
onUnauthorised = () => {},
|
||||
onFailed = () => {},
|
||||
onCompress = undefined,
|
||||
}: Record<string, any> = {}) {
|
||||
return new QueueSender(baseURL, onUnauthorised, onFailed, 10, 1000, onCompress)
|
||||
}
|
||||
|
||||
describe('QueueSender', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
// Test fetch first parameter + authorization header to be present
|
||||
|
||||
// authorise() / push()
|
||||
test('Does not call fetch if not authorised', () => {
|
||||
const queueSender = defaultQueueSender()
|
||||
const fetchMock = mockFetch(200)
|
||||
|
||||
queueSender.push(sampleArray)
|
||||
expect(fetchMock).not.toBeCalled()
|
||||
})
|
||||
test('Calls fetch on push() if authorised', () => {
|
||||
const queueSender = defaultQueueSender()
|
||||
const fetchMock = mockFetch(200)
|
||||
|
||||
queueSender.authorise(randomToken)
|
||||
expect(fetchMock).toBeCalledTimes(0)
|
||||
queueSender.push(sampleArray)
|
||||
expect(fetchMock).toBeCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject(requestMock)
|
||||
})
|
||||
test('Sends compressed request if onCompress is provided and compressed batch is included', () => {
|
||||
const queueSender = defaultQueueSender({ onCompress: () => true })
|
||||
const fetchMock = mockFetch(200)
|
||||
|
||||
// @ts-ignore
|
||||
const spyOnCompress = jest.spyOn(queueSender, 'onCompress')
|
||||
// @ts-ignore
|
||||
const spyOnSendNext = jest.spyOn(queueSender, 'sendNext')
|
||||
|
||||
queueSender.authorise(randomToken)
|
||||
queueSender.push(sampleArray)
|
||||
expect(spyOnCompress).toBeCalledTimes(1)
|
||||
queueSender.sendCompressed(sampleArray)
|
||||
expect(fetchMock).toBeCalledTimes(1)
|
||||
expect(spyOnSendNext).toBeCalledTimes(1)
|
||||
expect(spyOnCompress).toBeCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject(gzipRequestMock)
|
||||
})
|
||||
test('Calls fetch on authorisation if there was a push() call before', () => {
|
||||
const queueSender = defaultQueueSender()
|
||||
const fetchMock = mockFetch(200)
|
||||
|
||||
queueSender.push(sampleArray)
|
||||
queueSender.authorise(randomToken)
|
||||
expect(fetchMock).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
// .clean()
|
||||
test("Doesn't call fetch on push() after clean()", () => {
|
||||
const queueSender = defaultQueueSender()
|
||||
const fetchMock = mockFetch(200)
|
||||
jest.useFakeTimers()
|
||||
queueSender.authorise(randomToken)
|
||||
queueSender.clean()
|
||||
jest.runAllTimers()
|
||||
queueSender.push(sampleArray)
|
||||
expect(fetchMock).not.toBeCalled()
|
||||
})
|
||||
test("Doesn't call fetch on authorisation if there was push() & clean() calls before", () => {
|
||||
const queueSender = defaultQueueSender()
|
||||
const fetchMock = mockFetch(200)
|
||||
|
||||
queueSender.push(sampleArray)
|
||||
queueSender.clean()
|
||||
queueSender.authorise(randomToken)
|
||||
expect(fetchMock).not.toBeCalled()
|
||||
})
|
||||
|
||||
//Test N sequential ToBeCalledTimes(N)
|
||||
//Test N sequential pushes with different timeouts to be sequential
|
||||
|
||||
// onUnauthorised
|
||||
test('Calls onUnauthorized callback on 401', (done) => {
|
||||
const onUnauthorised = jest.fn()
|
||||
const queueSender = defaultQueueSender({
|
||||
onUnauthorised,
|
||||
})
|
||||
const fetchMock = mockFetch(401)
|
||||
queueSender.authorise(randomToken)
|
||||
queueSender.push(sampleArray)
|
||||
setTimeout(() => {
|
||||
// how to make test simpler and more explicit?
|
||||
expect(onUnauthorised).toBeCalled()
|
||||
done()
|
||||
}, 100)
|
||||
})
|
||||
//Test onFailure
|
||||
//Test attempts timeout/ attempts count (toBeCalledTimes on one batch)
|
||||
})
|
||||
190
tracker/tracker/src/NetworkWorker/index.ts
Normal file
190
tracker/tracker/src/NetworkWorker/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Type as MType } from '../common/messages.gen.js'
|
||||
import { FromWorkerData } from '../common/interaction.js'
|
||||
|
||||
import QueueSender from './QueueSender.js'
|
||||
import BatchWriter from './BatchWriter.js'
|
||||
|
||||
enum WorkerStatus {
|
||||
NotActive,
|
||||
Starting,
|
||||
Stopping,
|
||||
Active,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
const AUTO_SEND_INTERVAL = 10 * 1000
|
||||
|
||||
export default class NetworkWorker {
|
||||
sender: QueueSender | null = null
|
||||
writer: BatchWriter | null = null
|
||||
status: WorkerStatus = WorkerStatus.NotActive
|
||||
sendIntervalID: ReturnType<typeof setInterval> | null = null
|
||||
restartTimeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(private readonly app: any) {}
|
||||
|
||||
processEvent(data: any): void {
|
||||
if (data === null) {
|
||||
this.finalize()
|
||||
return
|
||||
}
|
||||
if (data.type === 'start') {
|
||||
this.status = WorkerStatus.Starting
|
||||
this.sender = new QueueSender(
|
||||
data.ingestPoint,
|
||||
() => {
|
||||
// onUnauthorised
|
||||
this.initiateRestart()
|
||||
},
|
||||
(reason) => {
|
||||
// onFailure
|
||||
this.initiateFailure(reason)
|
||||
},
|
||||
data.connAttemptCount,
|
||||
data.connAttemptGap,
|
||||
(batch) => {
|
||||
this.sendEvent({ type: 'compress', batch })
|
||||
},
|
||||
data.pageNo,
|
||||
)
|
||||
this.writer = new BatchWriter(
|
||||
data.pageNo,
|
||||
data.timestamp,
|
||||
data.url,
|
||||
(batch) => {
|
||||
this.sender && this.sender.push(batch)
|
||||
},
|
||||
data.tabId,
|
||||
() => this.sendEvent({ type: 'queue_empty' }),
|
||||
)
|
||||
if (this.sendIntervalID === null) {
|
||||
this.sendIntervalID = setInterval(this.finalize, AUTO_SEND_INTERVAL)
|
||||
}
|
||||
this.status = WorkerStatus.Active
|
||||
return
|
||||
}
|
||||
if (data === 'stop') {
|
||||
this.finalize()
|
||||
// eslint-disable-next-line
|
||||
this.reset().then(() => {
|
||||
this.status = WorkerStatus.Stopped
|
||||
})
|
||||
return
|
||||
}
|
||||
if (data === 'forceFlushBatch') {
|
||||
this.finalize()
|
||||
return
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
if (this.writer) {
|
||||
const w = this.writer
|
||||
data.forEach((message) => {
|
||||
if (message[0] === MType.SetPageVisibility) {
|
||||
if (message[1]) {
|
||||
// .hidden
|
||||
this.restartTimeoutID = setTimeout(() => this.initiateRestart(), 30 * 60 * 1000)
|
||||
} else if (this.restartTimeoutID) {
|
||||
clearTimeout(this.restartTimeoutID)
|
||||
}
|
||||
}
|
||||
w.writeMessage(message)
|
||||
})
|
||||
} else {
|
||||
this.sendEvent('not_init')
|
||||
this.initiateRestart()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.type === 'compressed') {
|
||||
if (!this.sender) {
|
||||
console.debug('OR WebWorker: sender not initialised. Compressed batch.')
|
||||
this.initiateRestart()
|
||||
return
|
||||
}
|
||||
data.batch && this.sender.sendCompressed(data.batch)
|
||||
}
|
||||
if (data.type === 'uncompressed') {
|
||||
if (!this.sender) {
|
||||
console.debug('OR WebWorker: sender not initialised. Uncompressed batch.')
|
||||
this.initiateRestart()
|
||||
return
|
||||
}
|
||||
data.batch && this.sender.sendUncompressed(data.batch)
|
||||
}
|
||||
if (data.type === 'auth') {
|
||||
if (!this.sender) {
|
||||
console.debug('OR WebWorker: sender not initialised. Received auth.')
|
||||
this.initiateRestart()
|
||||
return
|
||||
}
|
||||
if (!this.writer) {
|
||||
console.debug('OR WebWorker: writer not initialised. Received auth.')
|
||||
this.initiateRestart()
|
||||
return
|
||||
}
|
||||
this.sender.authorise(data.token)
|
||||
data.beaconSizeLimit && this.writer.setBeaconSizeLimit(data.beaconSizeLimit)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
finalize(): void {
|
||||
if (!this.writer) {
|
||||
return
|
||||
}
|
||||
this.writer.finaliseBatch()
|
||||
}
|
||||
|
||||
resetWriter(): void {
|
||||
if (this.writer) {
|
||||
this.writer.clean()
|
||||
// we don't need to wait for anything here since its sync
|
||||
this.writer = null
|
||||
}
|
||||
}
|
||||
|
||||
resetSender(): void {
|
||||
if (this.sender) {
|
||||
this.sender.clean()
|
||||
// allowing some time to send last batch
|
||||
setTimeout(() => {
|
||||
this.sender = null
|
||||
}, 20)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): Promise<any> {
|
||||
return new Promise((res) => {
|
||||
this.status = WorkerStatus.Stopping
|
||||
if (this.sendIntervalID !== null) {
|
||||
clearInterval(this.sendIntervalID)
|
||||
this.sendIntervalID = null
|
||||
}
|
||||
this.resetWriter()
|
||||
this.resetSender()
|
||||
setTimeout(() => {
|
||||
this.status = WorkerStatus.NotActive
|
||||
res(null)
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
initiateRestart(): void {
|
||||
if ([WorkerStatus.Stopped, WorkerStatus.Stopping].includes(this.status)) return
|
||||
this.sendEvent('a_stop')
|
||||
// eslint-disable-next-line
|
||||
this.reset().then(() => {
|
||||
this.sendEvent('a_start')
|
||||
})
|
||||
}
|
||||
|
||||
initiateFailure(reason: string): void {
|
||||
this.sendEvent({ type: 'failure', reason })
|
||||
void this.reset()
|
||||
}
|
||||
|
||||
sendEvent(data: any): void {
|
||||
this.app.processEvent(data)
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import type { Options as SessOptions } from './session.js'
|
|||
import Session from './session.js'
|
||||
import Ticker from './ticker.js'
|
||||
import { MaintainerOptions } from './nodes/maintainer.js'
|
||||
import NetworkWorker from '../../NetworkWorker/index.js'
|
||||
|
||||
interface TypedWorker extends Omit<Worker, 'postMessage'> {
|
||||
postMessage(data: ToWorkerData): void
|
||||
|
|
@ -71,7 +72,7 @@ interface OnStartInfo {
|
|||
* this value is injected during build time via rollup
|
||||
* */
|
||||
// @ts-ignore
|
||||
const workerBodyFn = global.WEBWORKER_BODY
|
||||
// const workerBodyFn = global.WEBWORKER_BODY
|
||||
const CANCELED = 'canceled' as const
|
||||
const uxtStorageKey = 'or_uxt_active'
|
||||
const bufferStorageKey = 'or_buffer_1'
|
||||
|
|
@ -251,7 +252,7 @@ export default class App {
|
|||
private readonly revID: string
|
||||
private activityState: ActivityState = ActivityState.NotActive
|
||||
private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin.
|
||||
private worker?: TypedWorker
|
||||
private worker?: NetworkWorker
|
||||
|
||||
public attributeSender: AttributeSender
|
||||
public featureFlags: FeatureFlags
|
||||
|
|
@ -736,19 +737,11 @@ export default class App {
|
|||
|
||||
private initWorker() {
|
||||
try {
|
||||
this.worker = new Worker(
|
||||
URL.createObjectURL(new Blob([workerBodyFn], { type: 'text/javascript' })),
|
||||
)
|
||||
this.worker.onerror = (e) => {
|
||||
this._debug('webworker_error', e)
|
||||
}
|
||||
this.worker.onmessage = ({ data }: MessageEvent<FromWorkerData>) => {
|
||||
this.handleWorkerMsg(data)
|
||||
}
|
||||
this.worker = new NetworkWorker(this)
|
||||
|
||||
const alertWorker = () => {
|
||||
if (this.worker) {
|
||||
this.worker.postMessage(null)
|
||||
this.worker.processEvent(null)
|
||||
}
|
||||
}
|
||||
// keep better tactics, discard others?
|
||||
|
|
@ -761,6 +754,10 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
public processEvent(data: FromWorkerData) {
|
||||
this.handleWorkerMsg(data)
|
||||
}
|
||||
|
||||
private handleWorkerMsg(data: FromWorkerData) {
|
||||
// handling 401 auth restart (new token assignment)
|
||||
if (data === 'a_stop') {
|
||||
|
|
@ -789,13 +786,13 @@ export default class App {
|
|||
gzip(data.batch, { mtime: 0 }, (err, result) => {
|
||||
if (err) {
|
||||
this.debug.error('Openreplay compression error:', err)
|
||||
this.worker?.postMessage({ type: 'uncompressed', batch: batch })
|
||||
this.worker?.processEvent({ type: 'uncompressed', batch: batch })
|
||||
} else {
|
||||
this.worker?.postMessage({ type: 'compressed', batch: result })
|
||||
this.worker?.processEvent({ type: 'compressed', batch: result })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.worker?.postMessage({ type: 'uncompressed', batch: batch })
|
||||
this.worker?.processEvent({ type: 'uncompressed', batch: batch })
|
||||
}
|
||||
} else if (data.type === 'queue_empty') {
|
||||
this.onSessionSent()
|
||||
|
|
@ -875,7 +872,7 @@ export default class App {
|
|||
requestIdleCb(() => {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()))
|
||||
this.worker?.postMessage(this.messages)
|
||||
this.worker?.processEvent(this.messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
})
|
||||
|
|
@ -916,7 +913,7 @@ export default class App {
|
|||
}
|
||||
|
||||
private postToWorker(messages: Array<Message>) {
|
||||
this.worker?.postMessage(messages)
|
||||
this.worker?.processEvent(messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(messages))
|
||||
}
|
||||
|
||||
|
|
@ -1297,7 +1294,7 @@ export default class App {
|
|||
public async uploadOfflineRecording() {
|
||||
this.stop(false)
|
||||
const timestamp = now()
|
||||
this.worker?.postMessage({
|
||||
this.worker?.processEvent({
|
||||
type: 'start',
|
||||
pageNo: this.session.incPageNo(),
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
|
|
@ -1335,7 +1332,7 @@ export default class App {
|
|||
beaconSizeLimit,
|
||||
projectID,
|
||||
} = await r.json()
|
||||
this.worker?.postMessage({
|
||||
this.worker?.processEvent({
|
||||
type: 'auth',
|
||||
token,
|
||||
beaconSizeLimit,
|
||||
|
|
@ -1401,7 +1398,7 @@ export default class App {
|
|||
})
|
||||
|
||||
const timestamp = now()
|
||||
this.worker?.postMessage({
|
||||
this.worker?.processEvent({
|
||||
type: 'start',
|
||||
pageNo: this.session.incPageNo(),
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
|
|
@ -1507,9 +1504,9 @@ export default class App {
|
|||
|
||||
if (socketOnly) {
|
||||
this.socketMode = true
|
||||
this.worker?.postMessage('stop')
|
||||
this.worker?.processEvent('stop')
|
||||
} else {
|
||||
this.worker?.postMessage({
|
||||
this.worker?.processEvent({
|
||||
type: 'auth',
|
||||
token,
|
||||
beaconSizeLimit,
|
||||
|
|
@ -1741,7 +1738,7 @@ export default class App {
|
|||
}
|
||||
|
||||
forceFlushBatch() {
|
||||
this.worker?.postMessage('forceFlushBatch')
|
||||
this.worker?.processEvent('forceFlushBatch')
|
||||
}
|
||||
|
||||
getTabId() {
|
||||
|
|
@ -1787,7 +1784,7 @@ export default class App {
|
|||
this.stopCallbacks.forEach((cb) => cb())
|
||||
this.tagWatcher.clear()
|
||||
if (this.worker && stopWorker) {
|
||||
this.worker.postMessage('stop')
|
||||
this.worker.processEvent('stop')
|
||||
}
|
||||
this.canvasRecorder?.clear()
|
||||
this.messages.length = 0
|
||||
|
|
|
|||
|
|
@ -230,71 +230,75 @@ export function deleteEventListener(
|
|||
}
|
||||
}
|
||||
|
||||
type Task = () => void | any
|
||||
class FIFOTaskScheduler {
|
||||
taskQueue: any[]
|
||||
isRunning: boolean
|
||||
private taskQueue: Task[]
|
||||
private isRunning: boolean
|
||||
|
||||
constructor() {
|
||||
this.taskQueue = []
|
||||
this.isRunning = false
|
||||
}
|
||||
|
||||
// Adds a task to the queue
|
||||
addTask(task: () => any) {
|
||||
addTask(task: Task): void {
|
||||
this.taskQueue.push(task)
|
||||
this.runTasks()
|
||||
}
|
||||
|
||||
// Runs tasks from the queue
|
||||
runTasks() {
|
||||
private runTasks(): void {
|
||||
if (this.isRunning || this.taskQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
|
||||
const executeNextTask = () => {
|
||||
const executeNextTask = (): void => {
|
||||
if (this.taskQueue.length === 0) {
|
||||
this.isRunning = false
|
||||
return
|
||||
}
|
||||
|
||||
// Get the next task and execute it
|
||||
const nextTask = this.taskQueue.shift()
|
||||
Promise.resolve(nextTask()).then(() => {
|
||||
|
||||
if (!nextTask) {
|
||||
requestAnimationFrame(() => executeNextTask())
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = nextTask()
|
||||
|
||||
// Use Promise.resolve() which works with both regular values and promises
|
||||
Promise.resolve(result)
|
||||
.catch((error) => {
|
||||
console.error('Task execution failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
requestAnimationFrame(() => executeNextTask())
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Task execution failed:', error)
|
||||
// Continue with next task even if this one failed
|
||||
requestAnimationFrame(() => executeNextTask())
|
||||
}
|
||||
}
|
||||
|
||||
executeNextTask()
|
||||
}
|
||||
|
||||
clearTasks(): void {
|
||||
this.taskQueue = []
|
||||
}
|
||||
|
||||
get pendingTasksCount(): number {
|
||||
return this.taskQueue.length
|
||||
}
|
||||
}
|
||||
|
||||
const scheduler = new FIFOTaskScheduler()
|
||||
export function requestIdleCb(callback: () => void) {
|
||||
// performance improvement experiment;
|
||||
|
||||
export function requestIdleCb(callback: Task): void {
|
||||
scheduler.addTask(callback)
|
||||
/**
|
||||
* This is a brief polyfill that suits our needs
|
||||
* I took inspiration from Microsoft Clarity polyfill on this one
|
||||
* then adapted it a little bit
|
||||
*
|
||||
* I'm very grateful for their bright idea
|
||||
* */
|
||||
// const taskTimeout = 3000
|
||||
// if (window.requestIdleCallback) {
|
||||
// return window.requestIdleCallback(callback, { timeout: taskTimeout })
|
||||
// } else {
|
||||
// const channel = new MessageChannel()
|
||||
// const incoming = channel.port1
|
||||
// const outgoing = channel.port2
|
||||
//
|
||||
// incoming.onmessage = (): void => {
|
||||
// callback()
|
||||
// }
|
||||
// requestAnimationFrame((): void => {
|
||||
// outgoing.postMessage(1)
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
export function simpleMerge<T>(defaultObj: T, givenObj: Partial<T>): T {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue