tracker fix crossdomain tracking issues (timestamps, duping, restarts); 14.0.10 beta

This commit is contained in:
nick-delirium 2024-10-04 17:22:28 +02:00
parent b12c71f277
commit 719a102996
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
8 changed files with 219 additions and 145 deletions

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC",
"version": "9.0.1",
"version": "9.0.2-beta.2",
"keywords": [
"WebRTC",
"assistance",

View file

@ -354,14 +354,18 @@ export default class Assist {
this.assistDemandedRestart = true
this.app.stop()
this.app.clearBuffers()
setTimeout(() => {
this.app.start().then(() => { this.assistDemandedRestart = false })
.then(() => {
this.remoteControl?.reconnect([id,])
})
.catch(e => app.debug.error(e))
// TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again
}, 400)
this.app.waitStatus(0)
.then(() => {
this.app.allowAppStart()
setTimeout(() => {
this.app.start().then(() => { this.assistDemandedRestart = false })
.then(() => {
this.remoteControl?.reconnect([id,])
})
.catch(e => app.debug.error(e))
// TODO: check if it's needed; basically allowing some time for the app to finish everything before starting again
}, 100)
})
}
})
socket.on('AGENTS_CONNECTED', (ids: string[]) => {

View file

@ -11,6 +11,9 @@ export default function(opts?: Partial<Options>) {
if (app === null || !navigator?.mediaDevices?.getUserMedia) {
return
}
if (app.insideIframe) {
return
}
if (!app.checkRequiredVersion || !app.checkRequiredVersion('REQUIRED_TRACKER_VERSION')) {
console.warn('OpenReplay Assist: couldn\'t load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met')
return

View file

@ -1 +1 @@
export const pkgVersion = "9.0.1";
export const pkgVersion = "9.0.2-beta.2";

View file

@ -2,6 +2,12 @@
- new webvitals messages source
# 14.0.10
- adjust timestamps for messages from tracker instances inside child iframes (if they were loaded later)
- restart child trackers if parent tracker is restarted
- fixes for general stability of crossdomain iframe tracking
# 14.0.9
- more stable crossdomain iframe tracking (refactored child/parent process discovery)

View file

@ -185,14 +185,14 @@ const proto = {
resp: 'never-gonna-let-you-down',
// regenerating id (copied other tab)
reg: 'never-gonna-run-around-and-desert-you',
// tracker inside a child iframe
iframeSignal: 'never-gonna-make-you-cry',
// getting node id for child iframe
iframeId: 'never-gonna-say-goodbye',
// batch of messages from an iframe window
iframeBatch: 'never-gonna-tell-a-lie-and-hurt-you',
// signal that parent is live
parentAlive: 'i-dont-know-more-lines',
iframeSignal: 'tracker inside a child iframe',
iframeId: 'getting node id for child iframe',
iframeBatch: 'batch of messages from an iframe window',
parentAlive: 'signal that parent is live',
killIframe: 'stop tracker inside frame',
startIframe: 'start tracker inside frame',
// checking updates
polling: 'hello-how-are-you-im-under-the-water-please-help-me',
} as const
export default class App {
@ -250,7 +250,7 @@ export default class App {
sessionToken: string | undefined,
options: Partial<Options>,
private readonly signalError: (error: string, apis: string[]) => void,
private readonly insideIframe: boolean,
public readonly insideIframe: boolean,
) {
this.contextId = Math.random().toString(36).slice(2)
this.projectKey = projectKey
@ -353,24 +353,28 @@ export default class App {
this.initWorker()
const thisTab = this.session.getTabId()
const catchParentMessage = (event: MessageEvent) => {
if (!this.active()) return
const { data } = event
if (!data) return
if (data.line === proto.parentAlive) {
this.parentActive = true
}
if (data.line === proto.iframeId) {
this.parentActive = true
this.rootId = data.id
this.session.setSessionToken(data.token as string)
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
}
}
window.addEventListener('message', catchParentMessage)
/**
* listen for messages from parent window, so we can signal that we're alive
* */
if (this.insideIframe) {
window.addEventListener('message', this.parentCrossDomainFrameListener)
setInterval(() => {
window.parent.postMessage(
{
line: proto.polling,
},
'*',
)
}, 250)
}
/**
* if we get a signal from child iframes, we check for their node_id and send it back,
* so they can act as if it was just a same-domain iframe
* */
if (!this.insideIframe) {
window.addEventListener('message', this.crossDomainIframeListener)
}
if (this.bc !== null) {
this.bc.postMessage({
@ -380,7 +384,7 @@ export default class App {
})
this.startTimeout = setTimeout(() => {
this.allowAppStart()
}, 500)
}, 250)
this.bc.onmessage = (ev: MessageEvent<RickRoll>) => {
if (ev.data.context === this.contextId) {
return
@ -416,104 +420,143 @@ export default class App {
checkStatus = () => {
return this.parentActive
}
/** used by child iframes for crossdomain only */
parentCrossDomainFrameListener = (event: MessageEvent) => {
const { data } = event
if (!data || event.source === window) return
if (data.line === proto.startIframe) {
if (this.active()) return
try {
this.allowAppStart()
void this.start()
} catch (e) {
console.error('children frame restart failed:', e)
}
}
if (data.line === proto.parentAlive) {
this.parentActive = true
}
if (data.line === proto.iframeId) {
this.parentActive = true
this.rootId = data.id
this.session.setSessionToken(data.token as string)
this.frameOderNumber = data.frameOrderNumber
this.debug.log('starting iframe tracking', data)
this.allowAppStart()
this.delay = data.frameTimeOffset
}
if (data.line === proto.killIframe) {
if (this.active()) {
this.stop()
}
}
}
/** track app instances in crossdomain child iframes */
crossdomainIframesModule = () => {
if (!this.insideIframe) {
/**
* if we get a signal from child iframes, we check for their node_id and send it back,
* so they can act as if it was just a same-domain iframe
* */
let crossdomainFrameCount = 0
const catchIframeMessage = (event: MessageEvent) => {
if (!this.active()) return;
const { data } = event
if (!data) return
if (data.line === proto.iframeSignal) {
// @ts-ignore
event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*')
const childIframeDomain = data.domain as string
const pageIframes = Array.from(document.querySelectorAll('iframe'))
this.pageFrames = pageIframes
const signalId = async () => {
const id = await this.checkNodeId(pageIframes, childIframeDomain)
if (id) {
try {
await this.waitStarted()
crossdomainFrameCount++
const token = this.session.getSessionToken()
const iframeData = {
line: proto.iframeId,
context: this.contextId,
domain: childIframeDomain,
id,
token,
frameOrderNumber: crossdomainFrameCount,
}
this.debug.log('iframe_data', iframeData)
// @ts-ignore
event.source?.postMessage(iframeData, '*')
} catch (e) {
console.error(e)
}
trackedFrames: number[] = []
crossDomainIframeListener = (event: MessageEvent) => {
if (!this.active() || event.source === window) return
const { data } = event
if (!data) return
if (data.line === proto.iframeSignal) {
// @ts-ignore
event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*')
const childIframeDomain = data.domain as string
const pageIframes = Array.from(document.querySelectorAll('iframe'))
this.pageFrames = pageIframes
const signalId = async () => {
const id = await this.checkNodeId(pageIframes, childIframeDomain)
if (id && !this.trackedFrames.includes(id)) {
try {
this.trackedFrames.push(id)
await this.waitStarted()
const token = this.session.getSessionToken()
const iframeData = {
line: proto.iframeId,
context: this.contextId,
domain: childIframeDomain,
id,
token,
frameOrderNumber: this.trackedFrames.length,
frameTimeOffset: this.timestamp(),
}
// @ts-ignore
event.source?.postMessage(iframeData, '*')
} catch (e) {
console.error(e)
}
void signalId()
}
/**
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
* plus we rewrite some of the messages to be relative to the main context/window
* */
if (data.line === proto.iframeBatch) {
const msgBatch = data.messages
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
if (msg[0] === MType.MouseMove) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, x, y] = msg
const { left, top } = frame.getBoundingClientRect()
fixedMessage = [type, x + left, y + top]
}
})
return fixedMessage
}
if (msg[0] === MType.MouseClick) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, id, hesitationTime, label, selector, normX, normY] = msg
const { left, top, width, height } = frame.getBoundingClientRect()
const contentWidth = document.documentElement.scrollWidth
const contentHeight = document.documentElement.scrollHeight
// (normalizedX * frameWidth + frameLeftOffset)/docSize
const fullX = (normX / 100) * width + left
const fullY = (normY / 100) * height + top
const fixedX = fullX / contentWidth
const fixedY = fullY / contentHeight
fixedMessage = [
type,
id,
hesitationTime,
label,
selector,
Math.round(fixedX * 1e3) / 1e1,
Math.round(fixedY * 1e3) / 1e1,
]
}
})
return fixedMessage
}
return msg
})
this.messages.push(...mappedMessages)
}
}
window.addEventListener('message', catchIframeMessage)
void signalId()
}
/**
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
* plus we rewrite some of the messages to be relative to the main context/window
* */
if (data.line === proto.iframeBatch) {
const msgBatch = data.messages
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
if (msg[0] === MType.MouseMove) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, x, y] = msg
const { left, top } = frame.getBoundingClientRect()
fixedMessage = [type, x + left, y + top]
}
})
return fixedMessage
}
if (msg[0] === MType.MouseClick) {
let fixedMessage = msg
this.pageFrames.forEach((frame) => {
if (frame.dataset.domain === event.data.domain) {
const [type, id, hesitationTime, label, selector, normX, normY] = msg
const { left, top, width, height } = frame.getBoundingClientRect()
const contentWidth = document.documentElement.scrollWidth
const contentHeight = document.documentElement.scrollHeight
// (normalizedX * frameWidth + frameLeftOffset)/docSize
const fullX = (normX / 100) * width + left
const fullY = (normY / 100) * height + top
const fixedX = fullX / contentWidth
const fixedY = fullY / contentHeight
fixedMessage = [
type,
id,
hesitationTime,
label,
selector,
Math.round(fixedX * 1e3) / 1e1,
Math.round(fixedY * 1e3) / 1e1,
]
}
})
return fixedMessage
}
return msg
})
this.messages.push(...mappedMessages)
}
if (data.line === proto.polling) {
if (!this.pollingQueue.length) {
return
}
while (this.pollingQueue.length) {
const msg = this.pollingQueue.shift()
// @ts-ignore
event.source?.postMessage({ line: msg }, '*')
}
}
}
pollingQueue: string[] = []
public bootChildrenFrames = async () => {
await this.waitStarted()
this.pollingQueue.push(proto.startIframe)
}
public killChildrenFrames = () => {
this.pollingQueue.push(proto.killIframe)
}
signalIframeTracker = () => {
@ -539,7 +582,7 @@ export default class App {
}
startTimeout: ReturnType<typeof setTimeout> | null = null
private allowAppStart() {
public allowAppStart() {
this.canStart = true
if (this.startTimeout) {
clearTimeout(this.startTimeout)
@ -778,27 +821,27 @@ export default class App {
this.commitCallbacks.push(cb)
}
attachStartCallback(cb: StartCallback, useSafe = false): void {
attachStartCallback = (cb: StartCallback, useSafe = false): void => {
if (useSafe) {
cb = this.safe(cb)
}
this.startCallbacks.push(cb)
}
attachStopCallback(cb: () => any, useSafe = false): void {
attachStopCallback = (cb: () => any, useSafe = false): void => {
if (useSafe) {
cb = this.safe(cb)
}
this.stopCallbacks.push(cb)
}
attachEventListener(
attachEventListener = (
target: EventTarget,
type: string,
listener: EventListener,
useSafe = true,
useCapture = true,
): void {
): void => {
if (useSafe) {
listener = this.safe(listener)
}
@ -1362,8 +1405,8 @@ export default class App {
}
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
this.activityState = ActivityState.Active
if (this.options.crossdomain?.enabled || this.insideIframe) {
this.crossdomainIframesModule()
if (this.options.crossdomain?.enabled && !this.insideIframe) {
void this.bootChildrenFrames()
}
if (canvasEnabled && !this.options.canvas.disableCanvas) {
@ -1499,9 +1542,13 @@ export default class App {
}
async waitStarted() {
return this.waitStatus(ActivityState.Active)
}
async waitStatus(status: ActivityState) {
return new Promise((resolve) => {
const check = () => {
if (this.activityState === ActivityState.Active) {
if (this.activityState === status) {
resolve(true)
} else {
setTimeout(check, 25)
@ -1516,9 +1563,6 @@ export default class App {
* and here we just apply 10ms delay just in case
* */
async start(...args: Parameters<App['_start']>): Promise<StartPromiseReturn> {
if (this.insideIframe) {
this.signalIframeTracker()
}
if (
this.activityState === ActivityState.Active ||
this.activityState === ActivityState.Starting
@ -1528,6 +1572,10 @@ export default class App {
return Promise.resolve(UnsuccessfulStart(reason))
}
if (this.insideIframe) {
this.signalIframeTracker()
}
if (!document.hidden) {
await this.waitStart()
return this._start(...args)
@ -1582,22 +1630,29 @@ export default class App {
stop(stopWorker = true): void {
if (this.activityState !== ActivityState.NotActive) {
console.trace('stopped')
try {
if (!this.insideIframe && this.options.crossdomain?.enabled) {
this.killChildrenFrames()
}
this.attributeSender.clear()
this.sanitizer.clear()
this.observer.disconnect()
this.nodes.clear()
this.ticker.stop()
this.stopCallbacks.forEach((cb) => cb())
this.debug.log('OpenReplay tracking stopped.')
this.tagWatcher.clear()
if (this.worker && stopWorker) {
this.worker.postMessage('stop')
}
this.canvasRecorder?.clear()
this.messages.length = 0
this.trackedFrames = []
this.parentActive = false
this.canStart = false
} finally {
this.activityState = ActivityState.NotActive
this.debug.log('OpenReplay tracking stopped.')
}
}
}

View file

@ -90,8 +90,12 @@ export default function (app: App, opts: Partial<Options>): void {
app.send(msg)
}
}
app.attachEventListener(context, 'unhandledrejection', handler)
app.attachEventListener(context, 'error', handler)
try {
app.attachEventListener(context, 'unhandledrejection', handler)
app.attachEventListener(context, 'error', handler)
} catch (e) {
console.error('Error while attaching to error proto contexts', e)
}
}
if (options.captureExceptions) {
app.observer.attachContextCallback(patchContext) // TODO: attach once-per-iframe (?)

View file

@ -148,9 +148,10 @@ export function createEventListener(
target[safeAddEventListener](event, cb, capture)
} catch (e) {
const msg = e.message
console.debug(
console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
)
}
}
@ -168,9 +169,10 @@ export function deleteEventListener(
target[safeRemoveEventListener](event, cb, capture)
} catch (e) {
const msg = e.message
console.debug(
console.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Openreplay: ${msg}; if this error is caused by an IframeObserver, ignore it`,
event,
)
}
}