Compare commits
30 commits
main
...
tracker-14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bbc8a43f4 | ||
|
|
30659bc39f | ||
|
|
c051a414e0 | ||
|
|
9dbbe60787 | ||
|
|
f978f868c8 | ||
|
|
80be8c28da | ||
|
|
f81395012a | ||
|
|
5d06c98e35 | ||
|
|
3aae1aafc1 | ||
|
|
54e2fa6626 | ||
|
|
1f2ff3a487 | ||
|
|
43515a418d | ||
|
|
b205dbed70 | ||
|
|
f041859a06 | ||
|
|
b5e681ff00 | ||
|
|
5009491f63 | ||
|
|
d39e9b8816 | ||
|
|
fa44300281 | ||
|
|
40a22efd56 | ||
|
|
0ddc9fcf7d | ||
|
|
1cecf6f926 | ||
|
|
72fe59bcbf | ||
|
|
ba5b2f9b82 | ||
|
|
6894da56da | ||
|
|
c76aed39f7 | ||
|
|
39df0c6761 | ||
|
|
4608cd2b63 | ||
|
|
6210a94258 | ||
|
|
4bf8bf0ffb | ||
|
|
74e1905cd2 |
53 changed files with 1781 additions and 474 deletions
|
|
@ -6,9 +6,10 @@ import {
|
|||
} from '@ant-design/icons';
|
||||
import { Button, InputNumber, Popover } from 'antd';
|
||||
import { Slider } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
|
||||
function DropdownAudioPlayer({
|
||||
|
|
@ -27,15 +28,24 @@ function DropdownAudioPlayer({
|
|||
const fileLengths = useRef<Record<string, number>>({});
|
||||
const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
|
||||
|
||||
const files = React.useMemo(() => audioEvents.map((pa) => {
|
||||
const data = pa.payload;
|
||||
const nativeTs = data.timestamp
|
||||
return {
|
||||
url: data.url,
|
||||
timestamp: data.timestamp,
|
||||
start: nativeTs ? nativeTs : pa.timestamp - sessionStart,
|
||||
};
|
||||
}), [audioEvents.length, sessionStart])
|
||||
const files = React.useMemo(
|
||||
() =>
|
||||
audioEvents.map((pa) => {
|
||||
const data = pa.payload;
|
||||
const nativeTs = data.timestamp;
|
||||
const startTs = nativeTs
|
||||
? nativeTs > sessionStart
|
||||
? nativeTs - sessionStart
|
||||
: nativeTs
|
||||
: pa.timestamp - sessionStart;
|
||||
return {
|
||||
url: data.url,
|
||||
timestamp: data.timestamp,
|
||||
start: startTs,
|
||||
};
|
||||
}),
|
||||
[audioEvents.length, sessionStart]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
||||
|
|
@ -43,10 +53,10 @@ function DropdownAudioPlayer({
|
|||
audio.loop = false;
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
fileLengths.current[url] = audio.duration;
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}, [audioRefs.current])
|
||||
});
|
||||
}, [audioRefs.current]);
|
||||
|
||||
const toggleMute = () => {
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
|
|
@ -124,7 +134,7 @@ function DropdownAudioPlayer({
|
|||
|
||||
useEffect(() => {
|
||||
const deltaMs = delta * 1000;
|
||||
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs)
|
||||
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs);
|
||||
if (deltaTime >= 250) {
|
||||
handleSeek(time);
|
||||
}
|
||||
|
|
@ -133,7 +143,7 @@ function DropdownAudioPlayer({
|
|||
const file = files.find((f) => f.url === url);
|
||||
const fileLength = fileLengths.current[url];
|
||||
if (file) {
|
||||
if (fileLength && (fileLength*1000)+file.start < time) {
|
||||
if (fileLength && fileLength * 1000 + file.start < time) {
|
||||
return;
|
||||
}
|
||||
if (time >= file.start) {
|
||||
|
|
@ -154,8 +164,8 @@ function DropdownAudioPlayer({
|
|||
if (audio) {
|
||||
audio.muted = isMuted;
|
||||
}
|
||||
})
|
||||
}, [isMuted])
|
||||
});
|
||||
}, [isMuted]);
|
||||
|
||||
useEffect(() => {
|
||||
changePlaybackSpeed(speed);
|
||||
|
|
@ -167,7 +177,7 @@ function DropdownAudioPlayer({
|
|||
const file = files.find((f) => f.url === url);
|
||||
const fileLength = fileLengths.current[url];
|
||||
if (file) {
|
||||
if (fileLength && (fileLength*1000)+file.start < time) {
|
||||
if (fileLength && fileLength * 1000 + file.start < time) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
|
@ -182,7 +192,8 @@ function DropdownAudioPlayer({
|
|||
setVolume(isMuted ? 0 : volume);
|
||||
}, [playing]);
|
||||
|
||||
const buttonIcon = 'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit'
|
||||
const buttonIcon =
|
||||
'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit';
|
||||
return (
|
||||
<div className={'relative'}>
|
||||
<div className={'flex items-center'} style={{ height: 24 }}>
|
||||
|
|
@ -204,20 +215,14 @@ function DropdownAudioPlayer({
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
cn(buttonIcon, 'rounded-l')
|
||||
}
|
||||
>
|
||||
<div className={cn(buttonIcon, 'rounded-l')}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</div>
|
||||
</Popover>
|
||||
<div
|
||||
onClick={toggleVisible}
|
||||
style={{ marginLeft: -1 }}
|
||||
className={
|
||||
cn(buttonIcon, 'rounded-r')
|
||||
}
|
||||
className={cn(buttonIcon, 'rounded-r')}
|
||||
>
|
||||
<CaretDownOutlined />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import logger from 'App/logger';
|
||||
import { resolveURL } from "../../messages/rewriter/urlResolve";
|
||||
|
||||
import type Screen from '../../Screen/Screen';
|
||||
import type { Message, SetNodeScroll } from '../../messages';
|
||||
|
|
@ -32,6 +31,8 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
private readonly vTexts: Map<number, VText> = new Map() // map vs object here?
|
||||
private readonly vElements: Map<number, VElement> = new Map()
|
||||
private readonly olVRoots: Map<number, OnloadVRoot> = new Map()
|
||||
/** required to keep track of iframes, frameId : vnodeId */
|
||||
private readonly iframeRoots: Record<number, number> = {}
|
||||
/** Constructed StyleSheets https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets
|
||||
* as well as <style> tag owned StyleSheets
|
||||
*/
|
||||
|
|
@ -219,6 +220,10 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
|
||||
vElem.prioritized = true
|
||||
}
|
||||
if (this.vElements.has(msg.id)) {
|
||||
logger.error("CreateElementNode: Node already exists", msg)
|
||||
return
|
||||
}
|
||||
this.vElements.set(msg.id, vElem)
|
||||
this.insertNode(msg)
|
||||
this.removeBodyScroll(msg.id, vElem)
|
||||
|
|
@ -316,6 +321,10 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
case MType.CreateIFrameDocument: {
|
||||
const vElem = this.vElements.get(msg.frameID)
|
||||
if (!vElem) { logger.error("CreateIFrameDocument: Node not found", msg); return }
|
||||
if (this.iframeRoots[msg.frameID] && !this.olVRoots.has(msg.id)) {
|
||||
this.olVRoots.delete(this.iframeRoots[msg.frameID])
|
||||
}
|
||||
this.iframeRoots[msg.frameID] = msg.id
|
||||
const vRoot = OnloadVRoot.fromVElement(vElem)
|
||||
vRoot.catch(e => logger.warn(e, msg))
|
||||
this.olVRoots.set(msg.id, vRoot)
|
||||
|
|
|
|||
BIN
tracker/tracker-assist/.yarn/install-state.gz
Normal file
BIN
tracker/tracker-assist/.yarn/install-state.gz
Normal file
Binary file not shown.
1
tracker/tracker-assist/.yarnrc.yml
Normal file
1
tracker/tracker-assist/.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
|
|
@ -1,3 +1,12 @@
|
|||
## 10.0.1
|
||||
|
||||
- some fixes for waitstatus usage
|
||||
|
||||
## 10.0.0
|
||||
|
||||
- memory handling improvements to prevent possible leaks on sessions with multiple canvas nodes
|
||||
- use new tracker.waitStatus api to wait for restarts
|
||||
|
||||
## 9.0.0
|
||||
|
||||
- support for message compression inside plugin (requires v1.18 frontend)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker-assist",
|
||||
"description": "Tracker plugin for screen assistance through the WebRTC",
|
||||
"version": "9.0.1",
|
||||
"version": "10.0.1",
|
||||
"keywords": [
|
||||
"WebRTC",
|
||||
"assistance",
|
||||
|
|
@ -16,11 +16,11 @@
|
|||
"tsrun": "tsc",
|
||||
"lint": "eslint src --ext .ts,.js --fix --quiet",
|
||||
"build": "bun run replace-pkg-version && bun run build-es && bun run build-cjs",
|
||||
"build-es": "rm -Rf lib && tsc && bun run replace-req-version",
|
||||
"build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && bun run replace-paths && bun run replace-req-version",
|
||||
"build-es": "rm -Rf lib && tsc --project tsconfig.json && bun run replace-req-version",
|
||||
"build-cjs": "rm -Rf cjs && tsc --project tsconfig-cjs.json && echo '{ \"type\": \"commonjs\" }' > cjs/package.json && bun run replace-req-version",
|
||||
"replace-paths": "replace-in-files cjs/* --string='@openreplay/tracker' --replacement='@openreplay/tracker/cjs' && replace-in-files cjs/* --string='/lib/' --replacement='/'",
|
||||
"replace-pkg-version": "sh pkgver.sh",
|
||||
"replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='13.0.0'",
|
||||
"replace-req-version": "replace-in-files lib/* cjs/* --string='REQUIRED_TRACKER_VERSION' --replacement='14.0.10'",
|
||||
"prepublishOnly": "bun run test && bun run build",
|
||||
"lint-front": "lint-staged",
|
||||
"test": "jest --coverage=false",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"socket.io-client": "^4.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@openreplay/tracker": "^14.0.0 || ^13.0.0"
|
||||
"@openreplay/tracker": "^14.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openreplay/tracker": "file:../tracker",
|
||||
|
|
@ -49,11 +49,12 @@
|
|||
"prettier": "^2.7.1",
|
||||
"replace-in-files-cli": "^1.0.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.6.0-dev.20211126"
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,cjs,jsx,ts,tsx}": [
|
||||
"eslint --fix --quiet"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.5.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export default class Assist {
|
|||
private socket: Socket | null = null
|
||||
private peer: Peer | null = null
|
||||
private canvasPeers: Record<number, Peer | null> = {}
|
||||
private canvasNodeCheckers: Map<number, any> = new Map()
|
||||
private assistDemandedRestart = false
|
||||
private callingState: CallingState = CallingState.False
|
||||
private remoteControl: RemoteControl | null = null;
|
||||
|
|
@ -354,14 +355,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[]) => {
|
||||
|
|
@ -375,14 +380,17 @@ export default class Assist {
|
|||
if (this.app.active()) {
|
||||
this.assistDemandedRestart = true
|
||||
this.app.stop()
|
||||
setTimeout(() => {
|
||||
this.app.start().then(() => { this.assistDemandedRestart = false })
|
||||
.then(() => {
|
||||
this.remoteControl?.reconnect(ids)
|
||||
})
|
||||
.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(ids)
|
||||
})
|
||||
.catch(e => app.debug.error(e))
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -670,6 +678,20 @@ export default class Assist {
|
|||
app.debug.error,
|
||||
)
|
||||
this.canvasMap.set(id, canvasHandler)
|
||||
if (this.canvasNodeCheckers.has(id)) {
|
||||
clearInterval(this.canvasNodeCheckers.get(id))
|
||||
}
|
||||
const int = setInterval(() => {
|
||||
const isPresent = node.ownerDocument.defaultView && node.isConnected
|
||||
if (!isPresent) {
|
||||
canvasHandler.stop()
|
||||
this.canvasMap.delete(id)
|
||||
this.canvasPeers[id]?.destroy()
|
||||
this.canvasPeers[id] = null
|
||||
clearInterval(int)
|
||||
}
|
||||
}, 5000)
|
||||
this.canvasNodeCheckers.set(id, int)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -699,6 +721,10 @@ export default class Assist {
|
|||
this.socket.disconnect()
|
||||
this.app.debug.log('Socket disconnected')
|
||||
}
|
||||
this.canvasMap.clear()
|
||||
this.canvasPeers = []
|
||||
this.canvasNodeCheckers.forEach((int) => clearInterval(int))
|
||||
this.canvasNodeCheckers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Mouse from './Mouse.js'
|
||||
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'
|
||||
import { controlConfirmDefault, } from './ConfirmWindow/defaults.js'
|
||||
import type { Options as AssistOptions, } from './Assist'
|
||||
import type { Options as AssistOptions, } from './Assist.js'
|
||||
|
||||
export enum RCStatus {
|
||||
Disabled,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export const pkgVersion = "9.0.1";
|
||||
export const pkgVersion = "10.0.1";
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"target": "es2017",
|
||||
"module": "es6",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"outDir": "./lib",
|
||||
|
|
|
|||
BIN
tracker/tracker/.yarn/install-state.gz
Normal file
BIN
tracker/tracker/.yarn/install-state.gz
Normal file
Binary file not shown.
1
tracker/tracker/.yarnrc.yml
Normal file
1
tracker/tracker/.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
nodeLinker: node-modules
|
||||
|
|
@ -1,239 +1,297 @@
|
|||
# 14.0.8
|
||||
## 14.0.13
|
||||
|
||||
- fixes for restart logic
|
||||
- fixed top context check in case of crossdomain placement
|
||||
- fixed crossdomain restart logic (when triggered via assist)
|
||||
- keep allowstart option on manual stop
|
||||
|
||||
## 14.0.11 & .12
|
||||
|
||||
- fix for node maintainer stability around `#document` nodes (mainly iframes field)
|
||||
|
||||
## 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
|
||||
- refactored usage of memory for everything regarding dom nodes to prevent possible memory leaks (i.e switched Map/Set to WeakMap/WeakSet where possible)
|
||||
- introduced configurable Maintainer to drop nodes that are not in the dom anymore from memory;
|
||||
|
||||
```
|
||||
interface MaintainerOptions {
|
||||
/**
|
||||
* Run cleanup each X ms
|
||||
*
|
||||
* @default 30 * 1000
|
||||
* */
|
||||
interval: number
|
||||
/**
|
||||
* Maintainer checks nodes in small batches over 50ms timeouts
|
||||
*
|
||||
* @default 2500
|
||||
* */
|
||||
batchSize: number
|
||||
/**
|
||||
* @default true
|
||||
* */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
new Tracker({
|
||||
...yourOptions,
|
||||
nodes: {
|
||||
maintainer: {
|
||||
interval: 60 * 1000,
|
||||
batchSize: 2500,
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- added `startCallback` option callback to tracker.start options (returns `{ success: false, reason: string } | { success: true, sessionToken, userUUID, sessionID }`)
|
||||
|
||||
## 14.0.9
|
||||
|
||||
- more stable crossdomain iframe tracking (refactored child/parent process discovery)
|
||||
- checks for bad start error
|
||||
|
||||
## 14.0.8
|
||||
|
||||
- use separate library to handle network requests ([@openreplay/network-proxy](https://www.npmjs.com/package/@openreplay/network-proxy))
|
||||
- fixes for window.message listeners
|
||||
|
||||
# 14.0.7
|
||||
## 14.0.7
|
||||
|
||||
- check for stopping status during restarts
|
||||
- restart if token expired during canvas fetch
|
||||
|
||||
# 14.0.6
|
||||
## 14.0.6
|
||||
|
||||
- support feature off toggle for feature flags and usability testing
|
||||
- additional checks for canvas snapshots
|
||||
|
||||
# 14.0.5
|
||||
## 14.0.5
|
||||
|
||||
- remove canvas snapshot interval if canvas is gone
|
||||
|
||||
# 14.0.4
|
||||
## 14.0.4
|
||||
|
||||
- remove reject from start
|
||||
|
||||
# 14.0.3
|
||||
## 14.0.3
|
||||
|
||||
- send integer instead of float for normalizedX/Y coords (basically moving from 0-100 to 0-10000 range)
|
||||
|
||||
# 14.0.2
|
||||
## 14.0.2
|
||||
|
||||
- fix logger check
|
||||
|
||||
# 14.0.0 & .1
|
||||
## 14.0.0 & .1
|
||||
|
||||
- titles for tabs
|
||||
- new `MouseClick` message to introduce heatmaps instead of clickmaps
|
||||
- crossdomain iframe tracking functionality
|
||||
- updated graphql plugin and messages
|
||||
|
||||
# 13.0.2
|
||||
## 13.0.2
|
||||
|
||||
- more file extensions for canvas
|
||||
|
||||
# 13.0.1
|
||||
## 13.0.1
|
||||
|
||||
- moved canvas snapshots to webp, additional option to utilize useAnimationFrame method (for webgl)
|
||||
- simpler, faster canvas recording manager
|
||||
|
||||
# 13.0.0
|
||||
## 13.0.0
|
||||
|
||||
- `assistOnly` flag for tracker options (EE only feature)
|
||||
|
||||
# 12.0.12
|
||||
## 12.0.12
|
||||
|
||||
- fix for potential redux plugin issues after .11 ...
|
||||
|
||||
# 12.0.11
|
||||
## 12.0.11
|
||||
|
||||
- better restart on unauth (new token assign for long sessions)
|
||||
- more safeguards around arraybuffer and dataview types for network proxy
|
||||
|
||||
# 12.0.10
|
||||
## 12.0.10
|
||||
|
||||
- improved logs for node binding errors, full nodelist clear before start, getSessionInfo method
|
||||
|
||||
# 12.0.9
|
||||
## 12.0.9
|
||||
|
||||
- moved logging to query
|
||||
|
||||
# 12.0.8
|
||||
## 12.0.8
|
||||
|
||||
- better logging for network batches
|
||||
|
||||
# 12.0.7
|
||||
## 12.0.7
|
||||
|
||||
- fixes for window.open reinit method
|
||||
|
||||
# 12.0.6
|
||||
## 12.0.6
|
||||
|
||||
- allow network sanitizer to return null (will ignore network req)
|
||||
|
||||
# 12.0.5
|
||||
## 12.0.5
|
||||
|
||||
- patch for img.ts srcset detector
|
||||
|
||||
# 12.0.4
|
||||
## 12.0.4
|
||||
|
||||
- patch for email sanitizer (supports + now)
|
||||
- update fflate version for better compression
|
||||
- `disableCanvas` option to disable canvas capture
|
||||
- better check for adopted stylesheets in doc (old browser support)
|
||||
|
||||
# 12.0.3
|
||||
## 12.0.3
|
||||
|
||||
- fixed scaling option for canvas (to ignore window.devicePixelRatio and always render the canvas as 1)
|
||||
|
||||
# 12.0.2
|
||||
## 12.0.2
|
||||
|
||||
- fix for canvas snapshot check
|
||||
|
||||
# 12.0.1
|
||||
## 12.0.1
|
||||
|
||||
- pause canvas snapshotting when its offscreen
|
||||
|
||||
# 12.0.0
|
||||
## 12.0.0
|
||||
|
||||
- offline session recording and manual sending
|
||||
- conditional recording with 30s buffer
|
||||
- websockets tracking hook
|
||||
|
||||
# 11.0.5
|
||||
## 11.0.5
|
||||
|
||||
- add method to restart canvas tracking (in case of context recreation)
|
||||
- scan dom tree for canvas els on tracker start
|
||||
|
||||
# 11.0.4
|
||||
## 11.0.4
|
||||
|
||||
- some additional security for canvas capture (check if canvas el itself is obscured/ignored)
|
||||
|
||||
# 11.0.3
|
||||
## 11.0.3
|
||||
|
||||
- move all logs under internal debugger
|
||||
- fix for XHR proxy ORSC 'abort' state
|
||||
|
||||
# 11.0.1 & 11.0.2
|
||||
## 11.0.1 & 11.0.2
|
||||
|
||||
- minor fixes and refactoring
|
||||
|
||||
# 11.0.0
|
||||
## 11.0.0
|
||||
|
||||
- canvas support
|
||||
- some safety guards for iframe components
|
||||
- user testing module
|
||||
|
||||
# 10.0.2
|
||||
## 10.0.2
|
||||
|
||||
- fix default ignore headers
|
||||
|
||||
# 10.0.1
|
||||
## 10.0.1
|
||||
|
||||
- network proxy api is now default turned on
|
||||
|
||||
# 10.0.0
|
||||
## 10.0.0
|
||||
|
||||
- networkRequest message changed to include `TransferredBodySize`
|
||||
- tracker now attempts to create proxy for beacon api as well (if its in scope of the current env)
|
||||
- safe wrapper for angular apps
|
||||
- better browser lag handling (and some performance improvements as a bonus)
|
||||
|
||||
# 9.0.11
|
||||
## 9.0.11
|
||||
|
||||
- new `resetTabOnWindowOpen` option to fix window.open issue with sessionStorage being inherited (replicating tabId bug), users still should use 'noopener=true' in window.open to prevent it in general...
|
||||
- do not create BC channel in iframe context, add regeneration of tabid incase of duplication
|
||||
|
||||
# 9.0.10
|
||||
## 9.0.10
|
||||
|
||||
- added `excludedResourceUrls` to timings options to better sanitize network data
|
||||
|
||||
# 9.0.9
|
||||
## 9.0.9
|
||||
|
||||
- Fix for `{disableStringDict: true}` behavior
|
||||
|
||||
# 9.0.8
|
||||
## 9.0.8
|
||||
|
||||
- added slight delay to iframe handler (rapid updates of stacked frames used to break player)
|
||||
|
||||
# 9.0.7
|
||||
## 9.0.7
|
||||
|
||||
- fix for `getSessionURL` method
|
||||
|
||||
# 9.0.6
|
||||
## 9.0.6
|
||||
|
||||
- added `tokenUrlMatcher` option to network settings, allowing to ingest session token header to custom allowed urls
|
||||
|
||||
# 9.0.5
|
||||
## 9.0.5
|
||||
|
||||
- same fixes but for fetch proxy
|
||||
|
||||
# 9.0.2 & 9.0.3 & 9.0.4
|
||||
## 9.0.2 & 9.0.3 & 9.0.4
|
||||
|
||||
- fixes for "setSessionTokenHeader" method
|
||||
|
||||
# 9.0.1
|
||||
## 9.0.1
|
||||
|
||||
- Warning about SSR mode
|
||||
- Prevent crashes due to network proxy in SSR
|
||||
|
||||
# 9.0.0
|
||||
## 9.0.0
|
||||
|
||||
- Option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
|
||||
- Introduced Feature flags api
|
||||
- Fixed input durations recorded on programmable autofill
|
||||
- change InputMode from enum to const Object
|
||||
|
||||
# 8.1.2
|
||||
## 8.1.2
|
||||
|
||||
- option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
|
||||
|
||||
# 8.1.1
|
||||
## 8.1.1
|
||||
|
||||
[collective patch]
|
||||
|
||||
- Console and network are now using proxy objects to capture calls (opt in for network), use ` { network: { useProxy: true } }` to enable it
|
||||
- Force disable Multitab feature for old browsers (2016 and older + safari 14)
|
||||
|
||||
# 8.0.0
|
||||
## 8.0.0
|
||||
|
||||
- **[breaking]** support for multi-tab sessions
|
||||
|
||||
# 7.0.4
|
||||
## 7.0.4
|
||||
|
||||
- option to disable string dictionary `{disableStringDict: true}` in Tracker constructor
|
||||
|
||||
# 7.0.3
|
||||
## 7.0.3
|
||||
|
||||
- Prevent auto restart after manual stop
|
||||
|
||||
# 7.0.2
|
||||
## 7.0.2
|
||||
|
||||
- fixed header sanitization for axios causing empty string in some cases
|
||||
|
||||
# 7.0.1
|
||||
## 7.0.1
|
||||
|
||||
- fix time inputs capturing
|
||||
- add option `{ network: { captureInIframes: boolean } }` to disable network tracking inside iframes (default true)
|
||||
- added option `{ network: { axiosInstances: AxiosInstance[] } }` to include custom axios instances for better tracking
|
||||
|
||||
# 7.0.0
|
||||
## 7.0.0
|
||||
|
||||
- **[breaking]** added gzip compression to large messages
|
||||
- fix email regexp to significantly improve performance
|
||||
|
||||
# 6.0.2
|
||||
## 6.0.2
|
||||
|
||||
- fix network tracking for same domain iframes created by js code
|
||||
|
||||
# 6.0.1
|
||||
## 6.0.1
|
||||
|
||||
- fix webworker writer re-init request
|
||||
- remove useless logs
|
||||
|
|
@ -241,7 +299,7 @@
|
|||
- fix iframe handling
|
||||
- optimise node counting for dom drop
|
||||
|
||||
# 6.0.0
|
||||
## 6.0.0
|
||||
|
||||
**(Compatible with OpenReplay v1.11.0+ only)**
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "14.0.8",
|
||||
"version": "14.0.14",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
@ -13,14 +13,28 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "./lib/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/cjs/index.js",
|
||||
"import": "./dist/lib/index.js",
|
||||
"types": "./dist/lib/main/index.d.ts"
|
||||
},
|
||||
"./cjs": {
|
||||
"require": "./dist/cjs/index.js",
|
||||
"types": "./dist/cjs/main/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/lib/**/*",
|
||||
"dist/cjs/**/*"
|
||||
],
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/lib/index.js",
|
||||
"types": "./dist/lib/main/index.d.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.js --fix --quiet",
|
||||
"clean": "rm -Rf build && rm -Rf lib && rm -Rf cjs",
|
||||
"tscRun": "tsc -b src/main && tsc -b src/webworker && tsc --project src/main/tsconfig-cjs.json",
|
||||
"rollup": "rollup --config rollup.config.js",
|
||||
"compile": "node --experimental-modules --experimental-json-modules scripts/compile.cjs",
|
||||
"build": "bun run clean && bun run tscRun && bun run rollup && bun run compile",
|
||||
"build": "yarn run clean && rollup --config rollup.config.js",
|
||||
"lint-front": "lint-staged",
|
||||
"test": "jest --coverage=false",
|
||||
"test:ci": "jest --coverage=true",
|
||||
|
|
@ -32,37 +46,33 @@
|
|||
"@jest/globals": "^29.3.1",
|
||||
"@rollup/plugin-babel": "^6.0.4",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"@typescript-eslint/parser": "^5.30.0",
|
||||
"eslint": "^7.8.0",
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.10.0",
|
||||
"@typescript-eslint/parser": "^8.10.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"jest": "^29.3.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
"lint-staged": "^13.0.3",
|
||||
"prettier": "^3.0.3",
|
||||
"replace-in-files": "^2.0.3",
|
||||
"rollup": "^4.1.4",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"semver": "^6.3.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.4"
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medv/finder": "^3.2.0",
|
||||
"@openreplay/network-proxy": "^1.0.3",
|
||||
"@openreplay/network-proxy": "^1.0.4",
|
||||
"error-stack-parser": "^2.0.6",
|
||||
"error-stack-parser-es": "^0.1.5",
|
||||
"fflate": "^0.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"eslint --fix --quiet"
|
||||
],
|
||||
"*.{json,md,html,js,jsx,ts,tsx}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
"packageManager": "yarn@4.5.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,81 @@
|
|||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import { babel } from '@rollup/plugin-babel'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
import typescript from '@rollup/plugin-typescript'
|
||||
import terser from '@rollup/plugin-terser'
|
||||
import replace from '@rollup/plugin-replace'
|
||||
import { rollup } from 'rollup'
|
||||
import { createRequire } from 'module'
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageConfig = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'build/webworker/index.js',
|
||||
output: {
|
||||
file: 'build/webworker.js',
|
||||
format: 'cjs',
|
||||
},
|
||||
plugins: [resolve(), babel({ babelHelpers: 'bundled' }), terser({ mangle: { reserved: ['$'] } })],
|
||||
export default async () => {
|
||||
const webworkerContent = await buildWebWorker()
|
||||
|
||||
const commonPlugins = [
|
||||
resolve(),
|
||||
// terser(),
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
TRACKER_VERSION: packageConfig.version,
|
||||
'global.WEBWORKER_BODY': JSON.stringify(webworkerContent),
|
||||
},
|
||||
}),
|
||||
]
|
||||
return [
|
||||
{
|
||||
input: 'src/main/index.ts',
|
||||
output: {
|
||||
dir: 'dist/lib',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
...commonPlugins,
|
||||
typescript({
|
||||
tsconfig: 'src/main/tsconfig.json',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
input: 'src/main/index.ts',
|
||||
output: {
|
||||
dir: 'dist/cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
...commonPlugins,
|
||||
typescript({
|
||||
tsconfig: 'src/main/tsconfig-cjs.json',
|
||||
}),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function buildWebWorker() {
|
||||
console.log('building wworker')
|
||||
const bundle = await rollup({
|
||||
input: 'src/webworker/index.ts',
|
||||
plugins: [
|
||||
resolve(),
|
||||
typescript({
|
||||
tsconfig: 'src/webworker/tsconfig.json',
|
||||
}),
|
||||
terser(),
|
||||
],
|
||||
})
|
||||
|
||||
const { output } = await bundle.generate({
|
||||
format: 'iife',
|
||||
name: 'WebWorker',
|
||||
inlineDynamicImports: true,
|
||||
})
|
||||
const webWorkerCode = output[0].code
|
||||
|
||||
console.log('webworker done!', output.length)
|
||||
|
||||
return webWorkerCode
|
||||
}
|
||||
|
|
|
|||
37
tracker/tracker/src/common/interaction.d.ts
vendored
Normal file
37
tracker/tracker/src/common/interaction.d.ts
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
tabId: string;
|
||||
} & Options;
|
||||
type Auth = {
|
||||
type: 'auth';
|
||||
token: string;
|
||||
beaconSizeLimit?: number;
|
||||
};
|
||||
export type ToWorkerData = null | 'stop' | Start | Auth | Array<Message> | {
|
||||
type: 'compressed';
|
||||
batch: Uint8Array;
|
||||
} | {
|
||||
type: 'uncompressed';
|
||||
batch: Uint8Array;
|
||||
} | 'forceFlushBatch' | 'check_queue';
|
||||
type Failure = {
|
||||
type: 'failure';
|
||||
reason: string;
|
||||
};
|
||||
type QEmpty = {
|
||||
type: 'queue_empty';
|
||||
};
|
||||
export type FromWorkerData = 'a_stop' | 'a_start' | Failure | 'not_init' | {
|
||||
type: 'compress';
|
||||
batch: Uint8Array;
|
||||
} | QEmpty;
|
||||
export {};
|
||||
1
tracker/tracker/src/common/interaction.js
Normal file
1
tracker/tracker/src/common/interaction.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
1
tracker/tracker/src/common/interaction.js.map
Normal file
1
tracker/tracker/src/common/interaction.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"interaction.js","sourceRoot":"","sources":["interaction.ts"],"names":[],"mappings":""}
|
||||
548
tracker/tracker/src/common/messages.gen.d.ts
vendored
Normal file
548
tracker/tracker/src/common/messages.gen.d.ts
vendored
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
export declare const enum Type {
|
||||
Timestamp = 0,
|
||||
SetPageLocationDeprecated = 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,
|
||||
NetworkRequestDeprecated = 21,
|
||||
ConsoleLog = 22,
|
||||
PageLoadTiming = 23,
|
||||
PageRenderTiming = 24,
|
||||
CustomEvent = 27,
|
||||
UserID = 28,
|
||||
UserAnonymousID = 29,
|
||||
Metadata = 30,
|
||||
CSSInsertRule = 37,
|
||||
CSSDeleteRule = 38,
|
||||
Fetch = 39,
|
||||
Profiler = 40,
|
||||
OTable = 41,
|
||||
StateAction = 42,
|
||||
ReduxDeprecated = 44,
|
||||
Vuex = 45,
|
||||
MobX = 46,
|
||||
NgRx = 47,
|
||||
GraphQLDeprecated = 48,
|
||||
PerformanceTrack = 49,
|
||||
StringDict = 50,
|
||||
SetNodeAttributeDict = 51,
|
||||
ResourceTimingDeprecated = 53,
|
||||
ConnectionInformation = 54,
|
||||
SetPageVisibility = 55,
|
||||
LoadFontFace = 57,
|
||||
SetNodeFocus = 58,
|
||||
LongTask = 59,
|
||||
SetNodeAttributeURLBased = 60,
|
||||
SetCSSDataURLBased = 61,
|
||||
TechnicalInfo = 63,
|
||||
CustomIssue = 64,
|
||||
CSSInsertRuleURLBased = 67,
|
||||
MouseClick = 68,
|
||||
MouseClickDeprecated = 69,
|
||||
CreateIFrameDocument = 70,
|
||||
AdoptedSSReplaceURLBased = 71,
|
||||
AdoptedSSInsertRuleURLBased = 73,
|
||||
AdoptedSSDeleteRule = 75,
|
||||
AdoptedSSAddOwner = 76,
|
||||
AdoptedSSRemoveOwner = 77,
|
||||
JSException = 78,
|
||||
Zustand = 79,
|
||||
BatchMetadata = 81,
|
||||
PartitionedMessage = 82,
|
||||
NetworkRequest = 83,
|
||||
WSChannel = 84,
|
||||
InputChange = 112,
|
||||
SelectionChange = 113,
|
||||
MouseThrashing = 114,
|
||||
UnbindNodes = 115,
|
||||
ResourceTiming = 116,
|
||||
TabChange = 117,
|
||||
TabData = 118,
|
||||
CanvasNode = 119,
|
||||
TagTrigger = 120,
|
||||
Redux = 121,
|
||||
SetPageLocation = 122,
|
||||
GraphQL = 123
|
||||
}
|
||||
export type Timestamp = [
|
||||
Type.Timestamp,
|
||||
number
|
||||
];
|
||||
export type SetPageLocationDeprecated = [
|
||||
Type.SetPageLocationDeprecated,
|
||||
string,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type SetViewportSize = [
|
||||
Type.SetViewportSize,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type SetViewportScroll = [
|
||||
Type.SetViewportScroll,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type CreateDocument = [
|
||||
Type.CreateDocument
|
||||
];
|
||||
export type CreateElementNode = [
|
||||
Type.CreateElementNode,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
boolean
|
||||
];
|
||||
export type CreateTextNode = [
|
||||
Type.CreateTextNode,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type MoveNode = [
|
||||
Type.MoveNode,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type RemoveNode = [
|
||||
Type.RemoveNode,
|
||||
number
|
||||
];
|
||||
export type SetNodeAttribute = [
|
||||
Type.SetNodeAttribute,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type RemoveNodeAttribute = [
|
||||
Type.RemoveNodeAttribute,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type SetNodeData = [
|
||||
Type.SetNodeData,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type SetNodeScroll = [
|
||||
Type.SetNodeScroll,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type SetInputTarget = [
|
||||
Type.SetInputTarget,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type SetInputValue = [
|
||||
Type.SetInputValue,
|
||||
number,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type SetInputChecked = [
|
||||
Type.SetInputChecked,
|
||||
number,
|
||||
boolean
|
||||
];
|
||||
export type MouseMove = [
|
||||
Type.MouseMove,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type NetworkRequestDeprecated = [
|
||||
Type.NetworkRequestDeprecated,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type ConsoleLog = [
|
||||
Type.ConsoleLog,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type PageLoadTiming = [
|
||||
Type.PageLoadTiming,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type PageRenderTiming = [
|
||||
Type.PageRenderTiming,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type CustomEvent = [
|
||||
Type.CustomEvent,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type UserID = [
|
||||
Type.UserID,
|
||||
string
|
||||
];
|
||||
export type UserAnonymousID = [
|
||||
Type.UserAnonymousID,
|
||||
string
|
||||
];
|
||||
export type Metadata = [
|
||||
Type.Metadata,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type CSSInsertRule = [
|
||||
Type.CSSInsertRule,
|
||||
number,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type CSSDeleteRule = [
|
||||
Type.CSSDeleteRule,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type Fetch = [
|
||||
Type.Fetch,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type Profiler = [
|
||||
Type.Profiler,
|
||||
string,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type OTable = [
|
||||
Type.OTable,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type StateAction = [
|
||||
Type.StateAction,
|
||||
string
|
||||
];
|
||||
export type ReduxDeprecated = [
|
||||
Type.ReduxDeprecated,
|
||||
string,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type Vuex = [
|
||||
Type.Vuex,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type MobX = [
|
||||
Type.MobX,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type NgRx = [
|
||||
Type.NgRx,
|
||||
string,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type GraphQLDeprecated = [
|
||||
Type.GraphQLDeprecated,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type PerformanceTrack = [
|
||||
Type.PerformanceTrack,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type StringDict = [
|
||||
Type.StringDict,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type SetNodeAttributeDict = [
|
||||
Type.SetNodeAttributeDict,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type ResourceTimingDeprecated = [
|
||||
Type.ResourceTimingDeprecated,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type ConnectionInformation = [
|
||||
Type.ConnectionInformation,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type SetPageVisibility = [
|
||||
Type.SetPageVisibility,
|
||||
boolean
|
||||
];
|
||||
export type LoadFontFace = [
|
||||
Type.LoadFontFace,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type SetNodeFocus = [
|
||||
Type.SetNodeFocus,
|
||||
number
|
||||
];
|
||||
export type LongTask = [
|
||||
Type.LongTask,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type SetNodeAttributeURLBased = [
|
||||
Type.SetNodeAttributeURLBased,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type SetCSSDataURLBased = [
|
||||
Type.SetCSSDataURLBased,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type TechnicalInfo = [
|
||||
Type.TechnicalInfo,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type CustomIssue = [
|
||||
Type.CustomIssue,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type CSSInsertRuleURLBased = [
|
||||
Type.CSSInsertRuleURLBased,
|
||||
number,
|
||||
string,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type MouseClick = [
|
||||
Type.MouseClick,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type MouseClickDeprecated = [
|
||||
Type.MouseClickDeprecated,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type CreateIFrameDocument = [
|
||||
Type.CreateIFrameDocument,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type AdoptedSSReplaceURLBased = [
|
||||
Type.AdoptedSSReplaceURLBased,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type AdoptedSSInsertRuleURLBased = [
|
||||
Type.AdoptedSSInsertRuleURLBased,
|
||||
number,
|
||||
string,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type AdoptedSSDeleteRule = [
|
||||
Type.AdoptedSSDeleteRule,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type AdoptedSSAddOwner = [
|
||||
Type.AdoptedSSAddOwner,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type AdoptedSSRemoveOwner = [
|
||||
Type.AdoptedSSRemoveOwner,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type JSException = [
|
||||
Type.JSException,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type Zustand = [
|
||||
Type.Zustand,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type BatchMetadata = [
|
||||
Type.BatchMetadata,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type PartitionedMessage = [
|
||||
Type.PartitionedMessage,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type NetworkRequest = [
|
||||
Type.NetworkRequest,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type WSChannel = [
|
||||
Type.WSChannel,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
string,
|
||||
string
|
||||
];
|
||||
export type InputChange = [
|
||||
Type.InputChange,
|
||||
number,
|
||||
string,
|
||||
boolean,
|
||||
string,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type SelectionChange = [
|
||||
Type.SelectionChange,
|
||||
number,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type MouseThrashing = [
|
||||
Type.MouseThrashing,
|
||||
number
|
||||
];
|
||||
export type UnbindNodes = [
|
||||
Type.UnbindNodes,
|
||||
number
|
||||
];
|
||||
export type ResourceTiming = [
|
||||
Type.ResourceTiming,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
boolean
|
||||
];
|
||||
export type TabChange = [
|
||||
Type.TabChange,
|
||||
string
|
||||
];
|
||||
export type TabData = [
|
||||
Type.TabData,
|
||||
string
|
||||
];
|
||||
export type CanvasNode = [
|
||||
Type.CanvasNode,
|
||||
string,
|
||||
number
|
||||
];
|
||||
export type TagTrigger = [
|
||||
Type.TagTrigger,
|
||||
number
|
||||
];
|
||||
export type Redux = [
|
||||
Type.Redux,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number
|
||||
];
|
||||
export type SetPageLocation = [
|
||||
Type.SetPageLocation,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
string
|
||||
];
|
||||
export type GraphQL = [
|
||||
Type.GraphQL,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number
|
||||
];
|
||||
type Message = Timestamp | SetPageLocationDeprecated | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | ReduxDeprecated | Vuex | MobX | NgRx | GraphQLDeprecated | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | MouseClickDeprecated | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | WSChannel | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData | CanvasNode | TagTrigger | Redux | SetPageLocation | GraphQL;
|
||||
export default Message;
|
||||
3
tracker/tracker/src/common/messages.gen.js
Normal file
3
tracker/tracker/src/common/messages.gen.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Auto-generated, do not edit
|
||||
/* eslint-disable */
|
||||
export {};
|
||||
1
tracker/tracker/src/common/messages.gen.js.map
Normal file
1
tracker/tracker/src/common/messages.gen.js.map
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"messages.gen.js","sourceRoot":"","sources":["messages.gen.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,oBAAoB"}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["es6"],
|
||||
}
|
||||
}
|
||||
1
tracker/tracker/src/common/tsconfig.tsbuildinfo
Normal file
1
tracker/tracker/src/common/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -20,7 +20,7 @@ interface Options {
|
|||
|
||||
class CanvasRecorder {
|
||||
private snapshots: Record<number, CanvasSnapshot> = {}
|
||||
private readonly intervals: NodeJS.Timeout[] = []
|
||||
private readonly intervals: ReturnType<typeof setInterval>[] = []
|
||||
private readonly interval: number
|
||||
private readonly fileExt: 'webp' | 'png' | 'jpeg' | 'avif'
|
||||
|
||||
|
|
@ -35,10 +35,8 @@ class CanvasRecorder {
|
|||
startTracking() {
|
||||
setTimeout(() => {
|
||||
this.app.nodes.scanTree(this.captureCanvas)
|
||||
this.app.nodes.attachNodeCallback((node: Node): void => {
|
||||
this.captureCanvas(node)
|
||||
})
|
||||
}, 500)
|
||||
this.app.nodes.attachNodeCallback(this.captureCanvas)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
restartTracking = () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import Message, {
|
|||
UserID,
|
||||
WSChannel,
|
||||
} from './messages.gen.js'
|
||||
import Nodes from './nodes.js'
|
||||
import Nodes from './nodes/index.js'
|
||||
import type { Options as ObserverOptions } from './observer/top_observer.js'
|
||||
import Observer from './observer/top_observer.js'
|
||||
import type { Options as SanitizerOptions } from './sanitizer.js'
|
||||
|
|
@ -40,6 +40,7 @@ import Sanitizer from './sanitizer.js'
|
|||
import type { Options as SessOptions } from './session.js'
|
||||
import Session from './session.js'
|
||||
import Ticker from './ticker.js'
|
||||
import { MaintainerOptions } from './nodes/maintainer.js'
|
||||
|
||||
interface TypedWorker extends Omit<Worker, 'postMessage'> {
|
||||
postMessage(data: ToWorkerData): void
|
||||
|
|
@ -52,6 +53,12 @@ export interface StartOptions {
|
|||
forceNew?: boolean
|
||||
sessionHash?: string
|
||||
assistOnly?: boolean
|
||||
/**
|
||||
* @deprecated We strongly advise to use .start().then instead.
|
||||
*
|
||||
* This method is kept for snippet compatibility only
|
||||
* */
|
||||
startCallback?: (result: StartPromiseReturn) => void
|
||||
}
|
||||
|
||||
interface OnStartInfo {
|
||||
|
|
@ -60,6 +67,11 @@ interface OnStartInfo {
|
|||
userUUID: string
|
||||
}
|
||||
|
||||
/**
|
||||
* this value is injected during build time via rollup
|
||||
* */
|
||||
// @ts-ignore
|
||||
const workerBodyFn = global.WEBWORKER_BODY
|
||||
const CANCELED = 'canceled' as const
|
||||
const uxtStorageKey = 'or_uxt_active'
|
||||
const bufferStorageKey = 'or_buffer_1'
|
||||
|
|
@ -161,6 +173,23 @@ type AppOptions = {
|
|||
}
|
||||
|
||||
network?: NetworkOptions
|
||||
/**
|
||||
* use this flag to force angular detection to be offline
|
||||
*
|
||||
* basically goes around window.Zone api changes to mutation observer
|
||||
* and event listeners
|
||||
* */
|
||||
forceNgOff?: boolean
|
||||
/**
|
||||
* This option is used to change how tracker handles potentially detached nodes
|
||||
*
|
||||
* defaults here are tested and proven to be lightweight and easy on cpu
|
||||
*
|
||||
* consult the docs before changing it
|
||||
* */
|
||||
nodes?: {
|
||||
maintainer: Partial<MaintainerOptions>
|
||||
}
|
||||
} & WebworkerOptions &
|
||||
SessOptions
|
||||
|
||||
|
|
@ -185,12 +214,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',
|
||||
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 {
|
||||
|
|
@ -237,7 +268,6 @@ export default class App {
|
|||
private rootId: number | null = null
|
||||
private pageFrames: HTMLIFrameElement[] = []
|
||||
private frameOderNumber = 0
|
||||
private readonly initialHostName = location.hostname
|
||||
private features = {
|
||||
'feature-flags': true,
|
||||
'usability-test': true,
|
||||
|
|
@ -248,7 +278,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
|
||||
|
|
@ -305,6 +335,7 @@ export default class App {
|
|||
__save_canvas_locally: false,
|
||||
useAnimationFrame: false,
|
||||
},
|
||||
forceNgOff: false,
|
||||
}
|
||||
this.options = simpleMerge(defaultOptions, options)
|
||||
|
||||
|
|
@ -321,17 +352,26 @@ export default class App {
|
|||
this.revID = this.options.revID
|
||||
this.localStorage = this.options.localStorage ?? window.localStorage
|
||||
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
|
||||
this.sanitizer = new Sanitizer(this, options)
|
||||
this.nodes = new Nodes(this.options.node_id)
|
||||
this.observer = new Observer(this, options)
|
||||
this.sanitizer = new Sanitizer({ app: this, options })
|
||||
this.nodes = new Nodes({
|
||||
node_id: this.options.node_id,
|
||||
forceNgOff: Boolean(options.forceNgOff),
|
||||
maintainer: this.options.nodes?.maintainer,
|
||||
})
|
||||
this.observer = new Observer({ app: this, options })
|
||||
this.ticker = new Ticker(this)
|
||||
this.ticker.attach(() => this.commit())
|
||||
this.debug = new Logger(this.options.__debug__)
|
||||
this.session = new Session(this, this.options)
|
||||
this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict))
|
||||
this.session = new Session({ app: this, options: this.options })
|
||||
this.attributeSender = new AttributeSender({
|
||||
app: this,
|
||||
isDictDisabled: Boolean(this.options.disableStringDict || this.options.crossdomain?.enabled),
|
||||
})
|
||||
this.featureFlags = new FeatureFlags(this)
|
||||
this.tagWatcher = new TagWatcher(this.sessionStorage, this.debug.error, (tag) => {
|
||||
this.send(TagTrigger(tag) as Message)
|
||||
this.tagWatcher = new TagWatcher({
|
||||
sessionStorage: this.sessionStorage,
|
||||
errLog: this.debug.error,
|
||||
onTag: (tag) => this.send(TagTrigger(tag) as Message),
|
||||
})
|
||||
this.session.attachUpdateCallback(({ userID, metadata }) => {
|
||||
if (userID != null) {
|
||||
|
|
@ -348,140 +388,33 @@ export default class App {
|
|||
this.session.applySessionHash(sessionToken)
|
||||
}
|
||||
|
||||
this.initWorker()
|
||||
|
||||
const thisTab = this.session.getTabId()
|
||||
|
||||
if (!this.insideIframe) {
|
||||
if (this.insideIframe) {
|
||||
/**
|
||||
* listen for messages from parent window, so we can signal that we're alive
|
||||
* */
|
||||
window.addEventListener('message', this.parentCrossDomainFrameListener)
|
||||
setInterval(() => {
|
||||
if (document.hidden) {
|
||||
return
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.polling,
|
||||
context: this.contextId,
|
||||
},
|
||||
options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
}, 250)
|
||||
} else {
|
||||
this.initWorker()
|
||||
/**
|
||||
* if we get a signal from child iframes, we check for their node_id and send it back,
|
||||
* so they can act as if it was just a same-domain iframe
|
||||
* */
|
||||
let crossdomainFrameCount = 0
|
||||
const catchIframeMessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
if (!data) return
|
||||
if (data.line === proto.iframeSignal) {
|
||||
const childIframeDomain = data.domain
|
||||
const pageIframes = Array.from(document.querySelectorAll('iframe'))
|
||||
this.pageFrames = pageIframes
|
||||
const signalId = async () => {
|
||||
let tries = 0
|
||||
while (tries < 10) {
|
||||
const id = this.checkNodeId(pageIframes, childIframeDomain)
|
||||
if (id) {
|
||||
this.waitStarted()
|
||||
.then(() => {
|
||||
crossdomainFrameCount++
|
||||
const token = this.session.getSessionToken()
|
||||
const iframeData = {
|
||||
line: proto.iframeId,
|
||||
context: this.contextId,
|
||||
domain: childIframeDomain,
|
||||
id,
|
||||
token,
|
||||
frameOrderNumber: crossdomainFrameCount,
|
||||
}
|
||||
this.debug.log('iframe_data', iframeData)
|
||||
// @ts-ignore
|
||||
event.source?.postMessage(iframeData, '*')
|
||||
})
|
||||
.catch(console.error)
|
||||
tries = 10
|
||||
break
|
||||
}
|
||||
tries++
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
void signalId()
|
||||
}
|
||||
/**
|
||||
* proxying messages from iframe to main body, so they can be in one batch (same indexes, etc)
|
||||
* plus we rewrite some of the messages to be relative to the main context/window
|
||||
* */
|
||||
if (data.line === proto.iframeBatch) {
|
||||
const msgBatch = data.messages
|
||||
const mappedMessages: Message[] = msgBatch.map((msg: Message) => {
|
||||
if (msg[0] === MType.MouseMove) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.dataset.domain === event.data.domain) {
|
||||
const [type, x, y] = msg
|
||||
const { left, top } = frame.getBoundingClientRect()
|
||||
fixedMessage = [type, x + left, y + top]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
if (msg[0] === MType.MouseClick) {
|
||||
let fixedMessage = msg
|
||||
this.pageFrames.forEach((frame) => {
|
||||
if (frame.dataset.domain === event.data.domain) {
|
||||
const [type, id, hesitationTime, label, selector, normX, normY] = msg
|
||||
const { left, top, width, height } = frame.getBoundingClientRect()
|
||||
|
||||
const contentWidth = document.documentElement.scrollWidth
|
||||
const contentHeight = document.documentElement.scrollHeight
|
||||
// (normalizedX * frameWidth + frameLeftOffset)/docSize
|
||||
const fullX = (normX / 100) * width + left
|
||||
const fullY = (normY / 100) * height + top
|
||||
const fixedX = fullX / contentWidth
|
||||
const fixedY = fullY / contentHeight
|
||||
|
||||
fixedMessage = [
|
||||
type,
|
||||
id,
|
||||
hesitationTime,
|
||||
label,
|
||||
selector,
|
||||
Math.round(fixedX * 1e3) / 1e1,
|
||||
Math.round(fixedY * 1e3) / 1e1,
|
||||
]
|
||||
}
|
||||
})
|
||||
return fixedMessage
|
||||
}
|
||||
return msg
|
||||
})
|
||||
this.messages.push(...mappedMessages)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', catchIframeMessage)
|
||||
this.attachStopCallback(() => {
|
||||
window.removeEventListener('message', catchIframeMessage)
|
||||
})
|
||||
} else {
|
||||
const catchParentMessage = (event: MessageEvent) => {
|
||||
const { data } = event
|
||||
if (!data) return
|
||||
if (data.line !== proto.iframeId) {
|
||||
return
|
||||
}
|
||||
this.rootId = data.id
|
||||
this.session.setSessionToken(data.token as string)
|
||||
this.frameOderNumber = data.frameOrderNumber
|
||||
this.debug.log('starting iframe tracking', data)
|
||||
this.allowAppStart()
|
||||
}
|
||||
window.addEventListener('message', catchParentMessage)
|
||||
this.attachStopCallback(() => {
|
||||
window.removeEventListener('message', catchParentMessage)
|
||||
})
|
||||
// communicating with parent window,
|
||||
// even if its crossdomain is possible via postMessage api
|
||||
const domain = this.initialHostName
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeSignal,
|
||||
source: thisTab,
|
||||
context: this.contextId,
|
||||
domain,
|
||||
},
|
||||
'*',
|
||||
)
|
||||
window.addEventListener('message', this.crossDomainIframeListener)
|
||||
}
|
||||
|
||||
if (this.bc !== null) {
|
||||
this.bc.postMessage({
|
||||
line: proto.ask,
|
||||
|
|
@ -490,7 +423,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
|
||||
|
|
@ -521,8 +454,241 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
/** used by child iframes for crossdomain only */
|
||||
parentActive = false
|
||||
checkStatus = () => {
|
||||
return this.parentActive
|
||||
}
|
||||
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()
|
||||
}
|
||||
if (data.line === proto.killIframe) {
|
||||
if (this.active()) {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* context ids for iframes,
|
||||
* order is not so important as long as its consistent
|
||||
* */
|
||||
trackedFrames: string[] = []
|
||||
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 signalId = async () => {
|
||||
if (event.source === null) {
|
||||
return console.error('Couldnt connect to event.source for child iframe tracking')
|
||||
}
|
||||
const id = await this.checkNodeId(event.source)
|
||||
if (!id) {
|
||||
this.debug.log('Couldnt get node id for iframe', event.source)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.trackedFrames.includes(data.context)) {
|
||||
this.debug.log('Trying to observe already added iframe; ignore if its a restart')
|
||||
} else {
|
||||
this.trackedFrames.push(data.context)
|
||||
}
|
||||
await this.waitStarted()
|
||||
const token = this.session.getSessionToken()
|
||||
const order = this.trackedFrames.findIndex((f) => f === data.context) + 1
|
||||
if (order === 0) {
|
||||
this.debug.error(
|
||||
'Couldnt get order number for iframe',
|
||||
data.context,
|
||||
this.trackedFrames,
|
||||
)
|
||||
}
|
||||
const iframeData = {
|
||||
line: proto.iframeId,
|
||||
id,
|
||||
token,
|
||||
// since indexes go from 0 we +1
|
||||
frameOrderNumber: order,
|
||||
}
|
||||
this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData)
|
||||
// @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.contentWindow === event.source) {
|
||||
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.contentWindow === event.source) {
|
||||
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.order.length) {
|
||||
return
|
||||
}
|
||||
const nextCommand = this.pollingQueue.order[0]
|
||||
if (nextCommand && this.pollingQueue[nextCommand].length === 0) {
|
||||
this.pollingQueue.order = this.pollingQueue.order.filter((c) => c !== nextCommand)
|
||||
return
|
||||
}
|
||||
if (this.pollingQueue[nextCommand].includes(data.context)) {
|
||||
this.pollingQueue[nextCommand] = this.pollingQueue[nextCommand].filter(
|
||||
(c: string) => c !== data.context,
|
||||
)
|
||||
// @ts-ignore
|
||||
event.source?.postMessage({ line: nextCommand }, '*')
|
||||
if (this.pollingQueue[nextCommand].length === 0) {
|
||||
this.pollingQueue.order.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* { command : [remaining iframes] }
|
||||
* + order of commands
|
||||
**/
|
||||
pollingQueue: Record<string, any> = {
|
||||
order: [],
|
||||
}
|
||||
private readonly addCommand = (cmd: string) => {
|
||||
this.pollingQueue.order.push(cmd)
|
||||
this.pollingQueue[cmd] = [...this.trackedFrames]
|
||||
}
|
||||
|
||||
public bootChildrenFrames = async () => {
|
||||
await this.waitStarted()
|
||||
this.addCommand(proto.startIframe)
|
||||
}
|
||||
|
||||
public killChildrenFrames = () => {
|
||||
this.addCommand(proto.killIframe)
|
||||
}
|
||||
|
||||
signalIframeTracker = () => {
|
||||
const thisTab = this.session.getTabId()
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeSignal,
|
||||
source: thisTab,
|
||||
context: this.contextId,
|
||||
},
|
||||
this.options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
|
||||
/**
|
||||
* since we need to wait uncertain amount of time
|
||||
* and I don't want to have recursion going on,
|
||||
* we'll just use a timeout loop with backoff
|
||||
* */
|
||||
const maxRetries = 10
|
||||
let retries = 0
|
||||
let delay = 250
|
||||
let cumulativeDelay = 0
|
||||
let stopAttempts = false
|
||||
|
||||
const checkAndSendMessage = () => {
|
||||
if (stopAttempts || this.checkStatus()) {
|
||||
stopAttempts = true
|
||||
return
|
||||
}
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeSignal,
|
||||
source: thisTab,
|
||||
context: this.contextId,
|
||||
},
|
||||
this.options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
this.debug.info('Trying to signal to parent, attempt:', retries + 1)
|
||||
retries++
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (this.checkStatus()) {
|
||||
stopAttempts = true
|
||||
break
|
||||
}
|
||||
cumulativeDelay += delay
|
||||
setTimeout(() => {
|
||||
checkAndSendMessage()
|
||||
}, cumulativeDelay)
|
||||
delay *= 1.5
|
||||
}
|
||||
}
|
||||
|
||||
startTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private allowAppStart() {
|
||||
public allowAppStart() {
|
||||
this.canStart = true
|
||||
if (this.startTimeout) {
|
||||
clearTimeout(this.startTimeout)
|
||||
|
|
@ -530,19 +696,46 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
private checkNodeId(iframes: HTMLIFrameElement[], domain: string) {
|
||||
for (const iframe of iframes) {
|
||||
if (iframe.dataset.domain === domain) {
|
||||
// @ts-ignore
|
||||
return iframe[this.options.node_id] as number | undefined
|
||||
private async checkNodeId(source: MessageEventSource): Promise<number | null> {
|
||||
let targetFrame
|
||||
if (this.pageFrames.length > 0) {
|
||||
targetFrame = this.pageFrames.find((frame) => frame.contentWindow === source)
|
||||
}
|
||||
if (!targetFrame || !this.pageFrames.length) {
|
||||
const pageIframes = Array.from(document.querySelectorAll('iframe'))
|
||||
this.pageFrames = pageIframes
|
||||
targetFrame = pageIframes.find((frame) => frame.contentWindow === source)
|
||||
}
|
||||
|
||||
if (!targetFrame) {
|
||||
return null
|
||||
}
|
||||
/**
|
||||
* Here we're trying to get node id from the iframe (which is kept in observer)
|
||||
* because of async nature of dom initialization, we give 100 retries with 100ms delay each
|
||||
* which equals to 10 seconds. This way we have a period where we give app some time to load
|
||||
* and tracker some time to parse the initial DOM tree even on slower devices
|
||||
* */
|
||||
let tries = 0
|
||||
while (tries < 100) {
|
||||
// @ts-ignore
|
||||
const potentialId = targetFrame[this.options.node_id]
|
||||
if (potentialId !== undefined) {
|
||||
tries = 100
|
||||
return potentialId
|
||||
} else {
|
||||
tries++
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private initWorker() {
|
||||
try {
|
||||
this.worker = new Worker(
|
||||
URL.createObjectURL(new Blob(['WEBWORKER_BODY'], { type: 'text/javascript' })),
|
||||
URL.createObjectURL(new Blob([workerBodyFn], { type: 'text/javascript' })),
|
||||
)
|
||||
this.worker.onerror = (e) => {
|
||||
this._debug('webworker_error', e)
|
||||
|
|
@ -571,7 +764,16 @@ export default class App {
|
|||
if (data === 'a_stop') {
|
||||
this.stop(false)
|
||||
} else if (data === 'a_start') {
|
||||
void this.start({}, true)
|
||||
this.waitStatus(ActivityState.NotActive).then(() => {
|
||||
this.allowAppStart()
|
||||
this.start(this.prevOpts, true)
|
||||
.then((r) => {
|
||||
this.debug.info('Worker restarted, session was too long', r)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.debug.error('Worker restart failed', e)
|
||||
})
|
||||
})
|
||||
} else if (data === 'not_init') {
|
||||
this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker')
|
||||
} else if (data.type === 'failure') {
|
||||
|
|
@ -649,28 +851,28 @@ export default class App {
|
|||
this.messages.length = 0
|
||||
return
|
||||
}
|
||||
if (this.worker === undefined || !this.messages.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.insideIframe) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
line: proto.iframeBatch,
|
||||
messages: this.messages,
|
||||
domain: this.initialHostName,
|
||||
},
|
||||
'*',
|
||||
this.options.crossdomain?.parentDomain ?? '*',
|
||||
)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (this.worker === undefined || !this.messages.length) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
requestIdleCb(() => {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()))
|
||||
// why I need to add opt chaining?
|
||||
this.worker?.postMessage(this.messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
|
|
@ -742,36 +944,39 @@ 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)
|
||||
}
|
||||
|
||||
// Use app.nodes.attachNodeListener for registered nodes instead
|
||||
attachEventListener(
|
||||
attachEventListener = (
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
useSafe = true,
|
||||
useCapture = true,
|
||||
): void {
|
||||
): void => {
|
||||
if (useSafe) {
|
||||
listener = this.safe(listener)
|
||||
}
|
||||
|
||||
const createListener = () =>
|
||||
target ? createEventListener(target, type, listener, useCapture) : null
|
||||
target
|
||||
? createEventListener(target, type, listener, useCapture, this.options.forceNgOff)
|
||||
: null
|
||||
const deleteListener = () =>
|
||||
target ? deleteEventListener(target, type, listener, useCapture) : null
|
||||
target
|
||||
? deleteEventListener(target, type, listener, useCapture, this.options.forceNgOff)
|
||||
: null
|
||||
|
||||
this.attachStartCallback(createListener, useSafe)
|
||||
this.attachStopCallback(deleteListener, useSafe)
|
||||
|
|
@ -1150,16 +1355,20 @@ export default class App {
|
|||
this.clearBuffers()
|
||||
}
|
||||
|
||||
prevOpts: StartOptions = {}
|
||||
private async _start(
|
||||
startOpts: StartOptions = {},
|
||||
resetByWorker = false,
|
||||
conditionName?: string,
|
||||
): Promise<StartPromiseReturn> {
|
||||
if (Object.keys(startOpts).length !== 0) {
|
||||
this.prevOpts = startOpts
|
||||
}
|
||||
const isColdStart = this.activityState === ActivityState.ColdStart
|
||||
if (isColdStart && this.coldInterval) {
|
||||
clearInterval(this.coldInterval)
|
||||
}
|
||||
if (!this.worker) {
|
||||
if (!this.worker && !this.insideIframe) {
|
||||
const reason = 'No worker found: perhaps, CSP is not set.'
|
||||
this.signalError(reason, [])
|
||||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
|
|
@ -1191,7 +1400,7 @@ export default class App {
|
|||
})
|
||||
|
||||
const timestamp = now()
|
||||
this.worker.postMessage({
|
||||
this.worker?.postMessage({
|
||||
type: 'start',
|
||||
pageNo: this.session.incPageNo(),
|
||||
ingestPoint: this.options.ingestPoint,
|
||||
|
|
@ -1239,7 +1448,7 @@ export default class App {
|
|||
const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}`
|
||||
return UnsuccessfulStart(reason)
|
||||
}
|
||||
if (!this.worker) {
|
||||
if (!this.worker && !this.insideIframe) {
|
||||
const reason = 'no worker found after start request (this should not happen in real world)'
|
||||
this.signalError(reason, [])
|
||||
return UnsuccessfulStart(reason)
|
||||
|
|
@ -1297,9 +1506,9 @@ export default class App {
|
|||
|
||||
if (socketOnly) {
|
||||
this.socketMode = true
|
||||
this.worker.postMessage('stop')
|
||||
this.worker?.postMessage('stop')
|
||||
} else {
|
||||
this.worker.postMessage({
|
||||
this.worker?.postMessage({
|
||||
type: 'auth',
|
||||
token,
|
||||
beaconSizeLimit,
|
||||
|
|
@ -1322,11 +1531,17 @@ export default class App {
|
|||
// TODO: start as early as possible (before receiving the token)
|
||||
/** after start */
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
|
||||
if (startOpts.startCallback) {
|
||||
startOpts.startCallback(SuccessfulStart(onStartInfo))
|
||||
}
|
||||
if (this.features['feature-flags']) {
|
||||
void this.featureFlags.reloadFlags()
|
||||
}
|
||||
await this.tagWatcher.fetchTags(this.options.ingestPoint, token)
|
||||
this.activityState = ActivityState.Active
|
||||
if (this.options.crossdomain?.enabled && !this.insideIframe) {
|
||||
void this.bootChildrenFrames()
|
||||
}
|
||||
|
||||
if (canvasEnabled && !this.options.canvas.disableCanvas) {
|
||||
this.canvasRecorder =
|
||||
|
|
@ -1338,7 +1553,6 @@ export default class App {
|
|||
fixedScaling: this.options.canvas.fixedCanvasScaling,
|
||||
useAnimationFrame: this.options.canvas.useAnimationFrame,
|
||||
})
|
||||
this.canvasRecorder.startTracking()
|
||||
}
|
||||
|
||||
/** --------------- COLD START BUFFER ------------------*/
|
||||
|
|
@ -1361,9 +1575,12 @@ export default class App {
|
|||
}
|
||||
this.ticker.start()
|
||||
}
|
||||
this.canvasRecorder?.startTracking()
|
||||
|
||||
if (this.features['usability-test']) {
|
||||
this.uxtManager = this.uxtManager ? this.uxtManager : new UserTestManager(this, uxtStorageKey)
|
||||
if (this.features['usability-test'] && !this.insideIframe) {
|
||||
this.uxtManager = this.uxtManager
|
||||
? this.uxtManager
|
||||
: new UserTestManager(this, uxtStorageKey)
|
||||
let uxtId: number | undefined
|
||||
const savedUxtTag = this.localStorage.getItem(uxtStorageKey)
|
||||
if (savedUxtTag) {
|
||||
|
|
@ -1396,6 +1613,11 @@ export default class App {
|
|||
} catch (reason) {
|
||||
this.stop()
|
||||
this.session.reset()
|
||||
if (!reason) {
|
||||
console.error('Unknown error during start')
|
||||
this.signalError('Unknown error', [])
|
||||
return UnsuccessfulStart('Unknown error')
|
||||
}
|
||||
if (reason === CANCELED) {
|
||||
this.signalError(CANCELED, [])
|
||||
return UnsuccessfulStart(CANCELED)
|
||||
|
|
@ -1442,21 +1664,23 @@ export default class App {
|
|||
|
||||
async waitStart() {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const int = setInterval(() => {
|
||||
if (this.canStart) {
|
||||
clearInterval(int)
|
||||
resolve(true)
|
||||
} else {
|
||||
setTimeout(check, 25)
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -1480,6 +1704,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)
|
||||
|
|
@ -1535,20 +1763,25 @@ export default class App {
|
|||
stop(stopWorker = true): void {
|
||||
if (this.activityState !== ActivityState.NotActive) {
|
||||
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.parentActive = false
|
||||
} finally {
|
||||
this.activityState = ActivityState.NotActive
|
||||
this.debug.log('OpenReplay tracking stopped.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ export default class Logger {
|
|||
return this.level >= level
|
||||
}
|
||||
|
||||
info = (...args: any[]) => {
|
||||
if (this.shouldLog(LogLevel.Verbose)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
console.info(...args)
|
||||
}
|
||||
}
|
||||
|
||||
log = (...args: any[]) => {
|
||||
if (this.shouldLog(LogLevel.Log)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { createEventListener, deleteEventListener } from '../utils.js'
|
||||
import { createEventListener, deleteEventListener } from '../../utils.js'
|
||||
import Maintainer, { MaintainerOptions } from './maintainer.js'
|
||||
|
||||
type NodeCallback = (node: Node, isStart: boolean) => void
|
||||
type ElementListener = [string, EventListener, boolean]
|
||||
|
||||
export interface NodesOptions {
|
||||
node_id: string
|
||||
forceNgOff: boolean
|
||||
maintainer?: Partial<MaintainerOptions>
|
||||
}
|
||||
|
||||
export default class Nodes {
|
||||
private nodes: Array<Node | void> = []
|
||||
private readonly nodes: Map<number, Node | void> = new Map()
|
||||
private totalNodeAmount = 0
|
||||
private readonly nodeCallbacks: Array<NodeCallback> = []
|
||||
private readonly elementListeners: Map<number, Array<ElementListener>> = new Map()
|
||||
private nextNodeId = 0
|
||||
private readonly node_id: string
|
||||
private readonly forceNgOff: boolean
|
||||
private readonly maintainer: Maintainer
|
||||
|
||||
constructor(private readonly node_id: string) {}
|
||||
constructor(params: NodesOptions) {
|
||||
this.node_id = params.node_id
|
||||
this.forceNgOff = params.forceNgOff
|
||||
this.maintainer = new Maintainer(this.nodes, this.unregisterNode, params.maintainer)
|
||||
this.maintainer.start()
|
||||
}
|
||||
|
||||
syntheticMode(frameOrder: number) {
|
||||
const maxSafeNumber = 9007199254740900
|
||||
const maxSafeNumber = Number.MAX_SAFE_INTEGER
|
||||
const placeholderSize = 99999999
|
||||
const nextFrameId = placeholderSize * frameOrder
|
||||
// I highly doubt that this will ever happen,
|
||||
|
|
@ -25,20 +40,25 @@ export default class Nodes {
|
|||
}
|
||||
|
||||
// Attached once per Tracker instance
|
||||
attachNodeCallback(nodeCallback: NodeCallback): void {
|
||||
this.nodeCallbacks.push(nodeCallback)
|
||||
attachNodeCallback(nodeCallback: NodeCallback): number {
|
||||
return this.nodeCallbacks.push(nodeCallback)
|
||||
}
|
||||
|
||||
scanTree = (cb: (node: Node | void) => void) => {
|
||||
this.nodes.forEach((node) => cb(node))
|
||||
this.nodes.forEach((node) => (node ? cb(node) : undefined))
|
||||
}
|
||||
|
||||
attachNodeListener(node: Node, type: string, listener: EventListener, useCapture = true): void {
|
||||
attachNodeListener = (
|
||||
node: Node,
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
useCapture = true,
|
||||
): void => {
|
||||
const id = this.getID(node)
|
||||
if (id === undefined) {
|
||||
return
|
||||
}
|
||||
createEventListener(node, type, listener, useCapture)
|
||||
createEventListener(node, type, listener, useCapture, this.forceNgOff)
|
||||
let listeners = this.elementListeners.get(id)
|
||||
if (listeners === undefined) {
|
||||
listeners = []
|
||||
|
|
@ -54,23 +74,23 @@ export default class Nodes {
|
|||
id = this.nextNodeId
|
||||
this.totalNodeAmount++
|
||||
this.nextNodeId++
|
||||
this.nodes[id] = node
|
||||
this.nodes.set(id, node)
|
||||
;(node as any)[this.node_id] = id
|
||||
}
|
||||
return [id, isNew]
|
||||
}
|
||||
|
||||
unregisterNode(node: Node): number | undefined {
|
||||
unregisterNode = (node: Node): number | undefined => {
|
||||
const id = (node as any)[this.node_id]
|
||||
if (id !== undefined) {
|
||||
;(node as any)[this.node_id] = undefined
|
||||
delete (node as any)[this.node_id]
|
||||
delete this.nodes[id]
|
||||
this.nodes.delete(id)
|
||||
const listeners = this.elementListeners.get(id)
|
||||
if (listeners !== undefined) {
|
||||
this.elementListeners.delete(id)
|
||||
listeners.forEach((listener) =>
|
||||
deleteEventListener(node, listener[0], listener[1], listener[2]),
|
||||
deleteEventListener(node, listener[0], listener[1], listener[2], this.forceNgOff),
|
||||
)
|
||||
}
|
||||
this.totalNodeAmount--
|
||||
|
|
@ -83,8 +103,7 @@ export default class Nodes {
|
|||
// but its still better than keeping dead nodes or undef elements
|
||||
// plus we keep our index positions for new/alive nodes
|
||||
// performance test: 3ms for 30k nodes with 17k dead ones
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
const node = this.nodes[i]
|
||||
for (const [_, node] of this.nodes) {
|
||||
if (node && !document.contains(node)) {
|
||||
this.unregisterNode(node)
|
||||
}
|
||||
|
|
@ -101,7 +120,7 @@ export default class Nodes {
|
|||
}
|
||||
|
||||
getNode(id: number) {
|
||||
return this.nodes[id]
|
||||
return this.nodes.get(id)
|
||||
}
|
||||
|
||||
getNodeCount() {
|
||||
|
|
@ -109,14 +128,13 @@ export default class Nodes {
|
|||
}
|
||||
|
||||
clear(): void {
|
||||
for (let id = 0; id < this.nodes.length; id++) {
|
||||
const node = this.nodes[id]
|
||||
if (!node) {
|
||||
continue
|
||||
for (const [_, node] of this.nodes) {
|
||||
if (node) {
|
||||
this.unregisterNode(node)
|
||||
}
|
||||
this.unregisterNode(node)
|
||||
}
|
||||
|
||||
this.nextNodeId = 0
|
||||
this.nodes.length = 0
|
||||
this.nodes.clear()
|
||||
}
|
||||
}
|
||||
122
tracker/tracker/src/main/app/nodes/maintainer.ts
Normal file
122
tracker/tracker/src/main/app/nodes/maintainer.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
const SECOND = 1000
|
||||
|
||||
function processMapInBatches(
|
||||
map: Map<number, Node | void>,
|
||||
batchSize: number,
|
||||
processBatchCallback: (node: Node) => void,
|
||||
) {
|
||||
const iterator = map.entries()
|
||||
|
||||
function processNextBatch() {
|
||||
const batch = []
|
||||
let result = iterator.next()
|
||||
|
||||
while (!result.done && batch.length < batchSize) {
|
||||
batch.push(result.value)
|
||||
result = iterator.next()
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
batch.forEach(([_, node]) => {
|
||||
if (node) {
|
||||
processBatchCallback(node)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(processNextBatch, 50)
|
||||
}
|
||||
}
|
||||
|
||||
processNextBatch()
|
||||
}
|
||||
|
||||
function isNodeStillActive(node: Node): [isCon: boolean, reason: string] {
|
||||
try {
|
||||
if (!node.isConnected) {
|
||||
return [false, 'not connected']
|
||||
}
|
||||
const nodeIsDocument = node.nodeType === Node.DOCUMENT_NODE
|
||||
const nodeWindow = nodeIsDocument
|
||||
? (node as Document).defaultView
|
||||
: node.ownerDocument?.defaultView
|
||||
|
||||
const ownerDoc = nodeIsDocument ? (node as Document) : node.ownerDocument
|
||||
if (!nodeWindow) {
|
||||
return [false, 'no window']
|
||||
}
|
||||
|
||||
if (nodeWindow.closed) {
|
||||
return [false, 'window closed']
|
||||
}
|
||||
|
||||
if (!ownerDoc?.documentElement.isConnected) {
|
||||
return [false, 'documentElement not connected']
|
||||
}
|
||||
|
||||
return [true, 'ok']
|
||||
} catch (e) {
|
||||
return [false, e]
|
||||
}
|
||||
}
|
||||
|
||||
export interface MaintainerOptions {
|
||||
/**
|
||||
* Run cleanup each X ms
|
||||
*
|
||||
* @default 30 * 1000
|
||||
* */
|
||||
interval: number
|
||||
/**
|
||||
* Maintainer checks nodes in small batches over 50ms timeouts
|
||||
*
|
||||
* @default 2500
|
||||
* */
|
||||
batchSize: number
|
||||
/**
|
||||
* @default true
|
||||
* */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
interval: SECOND * 30,
|
||||
batchSize: 2500,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
class Maintainer {
|
||||
private interval: ReturnType<typeof setInterval>
|
||||
private readonly options: MaintainerOptions
|
||||
constructor(
|
||||
private readonly nodes: Map<number, Node | void>,
|
||||
private readonly unregisterNode: (node: Node) => void,
|
||||
options?: Partial<MaintainerOptions>,
|
||||
) {
|
||||
this.options = { ...defaults, ...options }
|
||||
}
|
||||
|
||||
public start = () => {
|
||||
if (!this.options.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stop()
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
processMapInBatches(this.nodes, this.options.batchSize, (node) => {
|
||||
const isActive = isNodeStillActive(node)[0]
|
||||
if (!isActive) {
|
||||
this.unregisterNode(node)
|
||||
}
|
||||
})
|
||||
}, this.options.interval)
|
||||
}
|
||||
|
||||
public stop = () => {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Maintainer
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import Observer from './observer.js'
|
||||
import { CreateIFrameDocument } from '../messages.gen.js'
|
||||
import { CreateIFrameDocument, RemoveNode } from '../messages.gen.js'
|
||||
|
||||
export default class IFrameObserver extends Observer {
|
||||
docId: number | undefined
|
||||
observe(iframe: HTMLIFrameElement) {
|
||||
const doc = iframe.contentDocument
|
||||
const hostID = this.app.nodes.getID(iframe)
|
||||
if (!doc || hostID === undefined) {
|
||||
return
|
||||
} //log TODO common app.logger
|
||||
}
|
||||
// Have to observe document, because the inner <html> might be changed
|
||||
this.observeRoot(doc, (docID) => {
|
||||
//MBTODO: do not send if empty (send on load? it might be in-place iframe, like our replayer, which does not get loaded)
|
||||
|
|
@ -15,17 +16,18 @@ export default class IFrameObserver extends Observer {
|
|||
this.app.debug.log('OpenReplay: Iframe document not bound')
|
||||
return
|
||||
}
|
||||
this.docId = docID
|
||||
this.app.send(CreateIFrameDocument(hostID, docID))
|
||||
})
|
||||
}
|
||||
|
||||
syntheticObserve(selfId: number, doc: Document) {
|
||||
syntheticObserve(rootNodeId: number, doc: Document) {
|
||||
this.observeRoot(doc, (docID) => {
|
||||
if (docID === undefined) {
|
||||
this.app.debug.log('OpenReplay: Iframe document not bound')
|
||||
return
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(selfId, docID))
|
||||
this.app.send(CreateIFrameDocument(rootNodeId, docID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ type OffsetState = {
|
|||
}
|
||||
|
||||
export default class IFrameOffsets {
|
||||
private readonly states: Map<Document, OffsetState> = new Map()
|
||||
private states: WeakMap<Document, OffsetState> = new WeakMap()
|
||||
|
||||
private calcOffset(state: OffsetState): Offset {
|
||||
let parLeft = 0,
|
||||
|
|
@ -55,12 +55,10 @@ export default class IFrameOffsets {
|
|||
// anything more reliable? This does not cover all cases (layout changes are ignored, for ex.)
|
||||
parentDoc.addEventListener('scroll', invalidateOffset)
|
||||
parentDoc.defaultView?.addEventListener('resize', invalidateOffset)
|
||||
|
||||
this.states.set(doc, state)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.states.forEach((s) => s.clear())
|
||||
this.states.clear()
|
||||
this.states = new WeakMap()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMutationObserver, ngSafeBrowserMethod } from '../../utils.js'
|
||||
import { createMutationObserver } from '../../utils.js'
|
||||
import {
|
||||
RemoveNodeAttribute,
|
||||
SetNodeAttributeURLBased,
|
||||
|
|
@ -77,13 +77,11 @@ export default abstract class Observer {
|
|||
// mutations order is sequential
|
||||
const target = mutation.target
|
||||
const type = mutation.type
|
||||
|
||||
if (!isObservable(target)) {
|
||||
continue
|
||||
}
|
||||
if (type === 'childList') {
|
||||
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
||||
// Should be the same as bindTree(mutation.removedNodes[i]), but logic needs to be be untied
|
||||
if (isObservable(mutation.removedNodes[i])) {
|
||||
this.bindNode(mutation.removedNodes[i])
|
||||
}
|
||||
|
|
@ -114,13 +112,14 @@ export default abstract class Observer {
|
|||
}
|
||||
if (type === 'characterData') {
|
||||
this.textSet.add(id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
this.commitNodes()
|
||||
}) as MutationCallback,
|
||||
this.app.options.forceNgOff,
|
||||
)
|
||||
}
|
||||
|
||||
private clear(): void {
|
||||
this.commited.length = 0
|
||||
this.recents.clear()
|
||||
|
|
@ -129,10 +128,51 @@ export default abstract class Observer {
|
|||
this.textSet.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL: Unbinds the removed nodes in case of iframe src change.
|
||||
*
|
||||
* right now, we're relying on nodes.maintainer
|
||||
*/
|
||||
private handleIframeSrcChange(iframe: HTMLIFrameElement): void {
|
||||
const oldContentDocument = iframe.contentDocument
|
||||
if (oldContentDocument) {
|
||||
const id = this.app.nodes.getID(oldContentDocument)
|
||||
if (id !== undefined) {
|
||||
const walker = document.createTreeWalker(
|
||||
oldContentDocument,
|
||||
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) =>
|
||||
isIgnored(node) || this.app.nodes.getID(node) === undefined
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT,
|
||||
},
|
||||
// @ts-ignore
|
||||
false,
|
||||
)
|
||||
|
||||
let removed = 0
|
||||
const totalBeforeRemove = this.app.nodes.getNodeCount()
|
||||
|
||||
while (walker.nextNode()) {
|
||||
if (!iframe.contentDocument.contains(walker.currentNode)) {
|
||||
removed += 1
|
||||
this.app.nodes.unregisterNode(walker.currentNode)
|
||||
}
|
||||
}
|
||||
|
||||
const removedPercent = Math.floor((removed / totalBeforeRemove) * 100)
|
||||
if (removedPercent > 30) {
|
||||
this.app.send(UnbindNodes(removedPercent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendNodeAttribute(id: number, node: Element, name: string, value: string | null): void {
|
||||
if (isSVGElement(node)) {
|
||||
if (name.substr(0, 6) === 'xlink:') {
|
||||
name = name.substr(6)
|
||||
if (name.substring(0, 6) === 'xlink:') {
|
||||
name = name.substring(6)
|
||||
}
|
||||
if (value === null) {
|
||||
this.app.send(RemoveNodeAttribute(id, name))
|
||||
|
|
@ -152,7 +192,7 @@ export default abstract class Observer {
|
|||
name === 'integrity' ||
|
||||
name === 'crossorigin' ||
|
||||
name === 'autocomplete' ||
|
||||
name.substr(0, 2) === 'on'
|
||||
name.substring(0, 2) === 'on'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -357,6 +397,7 @@ export default abstract class Observer {
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private commitNode(id: number): boolean {
|
||||
const node = this.app.nodes.getNode(id)
|
||||
if (node === undefined) {
|
||||
|
|
@ -368,6 +409,7 @@ export default abstract class Observer {
|
|||
}
|
||||
return (this.commited[id] = this._commitNode(id, node))
|
||||
}
|
||||
|
||||
private commitNodes(isStart = false): void {
|
||||
let node
|
||||
this.recents.forEach((type, id) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import Observer from './observer.js'
|
||||
import { isElementNode, hasTag } from '../guards.js'
|
||||
import Network from '../../modules/network.js'
|
||||
|
||||
import IFrameObserver from './iframe_observer.js'
|
||||
import ShadowRootObserver from './shadow_root_observer.js'
|
||||
|
|
@ -22,16 +21,17 @@ const attachShadowNativeFn = IN_BROWSER ? Element.prototype.attachShadow : () =>
|
|||
export default class TopObserver extends Observer {
|
||||
private readonly options: Options
|
||||
private readonly iframeOffsets: IFrameOffsets = new IFrameOffsets()
|
||||
readonly app: App
|
||||
|
||||
constructor(app: App, options: Partial<Options>) {
|
||||
super(app, true)
|
||||
constructor(params: { app: App; options: Partial<Options> }) {
|
||||
super(params.app, true)
|
||||
this.app = params.app
|
||||
this.options = Object.assign(
|
||||
{
|
||||
captureIFrames: true,
|
||||
},
|
||||
options,
|
||||
params.options,
|
||||
)
|
||||
|
||||
// IFrames
|
||||
this.app.nodes.attachNodeCallback((node) => {
|
||||
if (
|
||||
|
|
@ -54,7 +54,7 @@ export default class TopObserver extends Observer {
|
|||
private readonly contextCallbacks: Array<ContextCallback> = []
|
||||
|
||||
// Attached once per Tracker instance
|
||||
private readonly contextsSet: Set<Window> = new Set()
|
||||
private readonly contextsSet: WeakSet<Window> = new WeakSet()
|
||||
attachContextCallback(cb: ContextCallback) {
|
||||
this.contextCallbacks.push(cb)
|
||||
}
|
||||
|
|
@ -63,29 +63,34 @@ export default class TopObserver extends Observer {
|
|||
return this.iframeOffsets.getDocumentOffset(doc)
|
||||
}
|
||||
|
||||
private iframeObservers: IFrameObserver[] = []
|
||||
private iframeObserversArr: IFrameObserver[] = []
|
||||
private iframeObservers: WeakMap<HTMLIFrameElement | Document, IFrameObserver> = new WeakMap()
|
||||
private docObservers: WeakMap<Document, IFrameObserver> = new WeakMap()
|
||||
private handleIframe(iframe: HTMLIFrameElement): void {
|
||||
let doc: Document | null = null
|
||||
// setTimeout is required. Otherwise some event listeners (scroll, mousemove) applied in modules
|
||||
// do not work on the iframe document when it 've been loaded dynamically ((why?))
|
||||
// do not work on the iframe document when it 've been loaded dynamically ((why?))
|
||||
const handle = this.app.safe(() =>
|
||||
setTimeout(() => {
|
||||
const id = this.app.nodes.getID(iframe)
|
||||
if (id === undefined) {
|
||||
//log
|
||||
return
|
||||
}
|
||||
if (!canAccessIframe(iframe)) return
|
||||
if (id === undefined || !canAccessIframe(iframe)) return
|
||||
const currentWin = iframe.contentWindow
|
||||
const currentDoc = iframe.contentDocument
|
||||
if (currentDoc && currentDoc !== doc) {
|
||||
const observer = new IFrameObserver(this.app)
|
||||
this.iframeObservers.push(observer)
|
||||
observer.observe(iframe) // TODO: call unregisterNode for the previous doc if present (incapsulate: one iframe - one observer)
|
||||
doc = currentDoc
|
||||
|
||||
this.iframeOffsets.observe(iframe)
|
||||
if (!currentDoc) {
|
||||
this.app.debug.warn('no doc for iframe found', iframe)
|
||||
return
|
||||
}
|
||||
if (currentDoc && this.docObservers.has(currentDoc)) {
|
||||
this.app.debug.info('doc already observed for', id)
|
||||
return
|
||||
}
|
||||
const observer = new IFrameObserver(this.app)
|
||||
this.iframeObservers.set(iframe, observer)
|
||||
this.docObservers.set(currentDoc, observer)
|
||||
this.iframeObserversArr.push(observer)
|
||||
|
||||
observer.observe(iframe)
|
||||
|
||||
this.iframeOffsets.observe(iframe)
|
||||
if (
|
||||
currentWin &&
|
||||
// Sometimes currentWin.window is null (not in specification). Such window object is not functional
|
||||
|
|
@ -94,20 +99,20 @@ export default class TopObserver extends Observer {
|
|||
//TODO: more explicit logic
|
||||
) {
|
||||
this.contextsSet.add(currentWin)
|
||||
//@ts-ignore https://github.com/microsoft/TypeScript/issues/41684
|
||||
// @ts-ignore https://github.com/microsoft/TypeScript/issues/41684
|
||||
this.contextCallbacks.forEach((cb) => cb(currentWin))
|
||||
}
|
||||
// we need this delay because few iframes stacked one in another with rapid updates will break the player (or browser engine rather?)
|
||||
}, 100),
|
||||
}, 250),
|
||||
)
|
||||
iframe.addEventListener('load', handle) // why app.attachEventListener not working?
|
||||
iframe.addEventListener('load', handle)
|
||||
handle()
|
||||
}
|
||||
|
||||
private shadowRootObservers: ShadowRootObserver[] = []
|
||||
private shadowRootObservers: WeakMap<ShadowRoot, ShadowRootObserver> = new WeakMap()
|
||||
private handleShadowRoot(shRoot: ShadowRoot) {
|
||||
const observer = new ShadowRootObserver(this.app)
|
||||
this.shadowRootObservers.push(observer)
|
||||
this.shadowRootObservers.set(shRoot, observer)
|
||||
observer.observe(shRoot.host)
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +126,6 @@ export default class TopObserver extends Observer {
|
|||
observer.handleShadowRoot(shadow)
|
||||
return shadow
|
||||
}
|
||||
|
||||
this.app.nodes.clear()
|
||||
// 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>
|
||||
|
|
@ -140,7 +144,7 @@ export default class TopObserver extends Observer {
|
|||
)
|
||||
}
|
||||
|
||||
crossdomainObserve(selfId: number, frameOder: number) {
|
||||
crossdomainObserve(rootNodeId: number, frameOder: number) {
|
||||
const observer = this
|
||||
Element.prototype.attachShadow = function () {
|
||||
// eslint-disable-next-line
|
||||
|
|
@ -151,17 +155,18 @@ export default class TopObserver extends Observer {
|
|||
this.app.nodes.clear()
|
||||
this.app.nodes.syntheticMode(frameOder)
|
||||
const iframeObserver = new IFrameObserver(this.app)
|
||||
this.iframeObservers.push(iframeObserver)
|
||||
iframeObserver.syntheticObserve(selfId, window.document)
|
||||
this.iframeObservers.set(window.document, iframeObserver)
|
||||
iframeObserver.syntheticObserve(rootNodeId, window.document)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.iframeOffsets.clear()
|
||||
Element.prototype.attachShadow = attachShadowNativeFn
|
||||
this.iframeObservers.forEach((o) => o.disconnect())
|
||||
this.iframeObservers = []
|
||||
this.shadowRootObservers.forEach((o) => o.disconnect())
|
||||
this.shadowRootObservers = []
|
||||
this.iframeObserversArr.forEach((observer) => observer.disconnect())
|
||||
this.iframeObserversArr = []
|
||||
this.iframeObservers = new WeakMap()
|
||||
this.shadowRootObservers = new WeakMap()
|
||||
this.docObservers = new WeakMap()
|
||||
super.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,17 +23,16 @@ export default class Sanitizer {
|
|||
private readonly obscured: Set<number> = new Set()
|
||||
private readonly hidden: Set<number> = new Set()
|
||||
private readonly options: Options
|
||||
private readonly app: App
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
options: Partial<Options>,
|
||||
) {
|
||||
constructor(params: { app: App; options?: Partial<Options> }) {
|
||||
this.app = params.app
|
||||
this.options = Object.assign(
|
||||
{
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
},
|
||||
options,
|
||||
params.options,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,13 @@ export default class Session {
|
|||
private tabId: string
|
||||
public userInfo: UserInfo
|
||||
private token: string | undefined
|
||||
private readonly app: App
|
||||
private readonly options: Options
|
||||
|
||||
constructor(params: { app: App; options: Options }) {
|
||||
this.app = params.app
|
||||
this.options = params.options
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly options: Options,
|
||||
) {
|
||||
this.createTabId()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,14 @@ function processOptions(obj: any): obj is Options {
|
|||
return true
|
||||
}
|
||||
|
||||
const canAccessTop = () => {
|
||||
try {
|
||||
return Boolean(window.top)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default class API {
|
||||
public featureFlags: FeatureFlags
|
||||
|
||||
|
|
@ -104,9 +112,13 @@ export default class API {
|
|||
constructor(private readonly options: Options) {
|
||||
this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled)
|
||||
if (!IN_BROWSER || !processOptions(options)) {
|
||||
console.error('OpenReplay: tracker called in a non-browser environment or with invalid options')
|
||||
return
|
||||
}
|
||||
if ((window as any).__OPENREPLAY__) {
|
||||
if (
|
||||
(window as any).__OPENREPLAY__ ||
|
||||
(!this.crossdomainMode && inIframe() && canAccessTop() && (window.top as any)?.__OPENREPLAY__)
|
||||
) {
|
||||
console.error('OpenReplay: one tracker instance has been initialised already')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ export class StringDictionary {
|
|||
|
||||
export default class AttributeSender {
|
||||
private dict = new StringDictionary()
|
||||
private readonly app: App
|
||||
private readonly isDictDisabled: boolean
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly isDictDisabled: boolean,
|
||||
) {}
|
||||
constructor(options: { app: App; isDictDisabled: boolean }) {
|
||||
this.app = options.app
|
||||
this.isDictDisabled = options.isDictDisabled
|
||||
}
|
||||
|
||||
public sendSetAttribute(id: number, name: string, value: string) {
|
||||
if (this.isDictDisabled) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type App from '../app/index.js'
|
||||
import type Message from '../app/messages.gen.js'
|
||||
import { JSException } from '../app/messages.gen.js'
|
||||
import ErrorStackParser from 'error-stack-parser'
|
||||
import { parse } from 'error-stack-parser-es'
|
||||
|
||||
export interface Options {
|
||||
captureExceptions: boolean
|
||||
|
|
@ -34,7 +34,7 @@ export function getExceptionMessage(
|
|||
): Message {
|
||||
let stack = fallbackStack
|
||||
try {
|
||||
stack = ErrorStackParser.parse(error)
|
||||
stack = parse(error)
|
||||
} catch (e) {}
|
||||
return JSException(error.name, error.message, JSON.stringify(stack), JSON.stringify(metadata))
|
||||
}
|
||||
|
|
@ -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 (?)
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export default function (app: App): void {
|
|||
}
|
||||
}
|
||||
}) as MutationCallback,
|
||||
app.options.forceNgOff,
|
||||
)
|
||||
|
||||
app.attachStopCallback(() => {
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default function (app: App, options?: MouseHandlerOptions): void {
|
|||
let direction = 0
|
||||
let directionChangeCount = 0
|
||||
let distance = 0
|
||||
let checkIntervalId: NodeJS.Timer
|
||||
let checkIntervalId: ReturnType<typeof setInterval>
|
||||
const shakeThreshold = 0.008
|
||||
const shakeCheckInterval = 225
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ class TagWatcher {
|
|||
intervals: Record<string, ReturnType<typeof setInterval>> = {}
|
||||
tags: { id: number; selector: string }[] = []
|
||||
observer: IntersectionObserver
|
||||
private readonly sessionStorage: Storage
|
||||
private readonly errLog: (args: any[]) => void
|
||||
private readonly onTag: (tag: number) => void
|
||||
|
||||
constructor(
|
||||
private readonly sessionStorage: Storage,
|
||||
private readonly errLog: (args: any[]) => void,
|
||||
private readonly onTag: (tag: number) => void,
|
||||
) {
|
||||
constructor(params: {
|
||||
sessionStorage: Storage
|
||||
errLog: (args: any[]) => void
|
||||
onTag: (tag: number) => void
|
||||
}) {
|
||||
this.sessionStorage = params.sessionStorage
|
||||
this.errLog = params.errLog
|
||||
this.onTag = params.onTag
|
||||
// @ts-ignore
|
||||
const tags: { id: number; selector: string }[] = JSON.parse(
|
||||
sessionStorage.getItem(WATCHED_TAGS_KEY) ?? '[]',
|
||||
params.sessionStorage.getItem(WATCHED_TAGS_KEY) ?? '[]',
|
||||
)
|
||||
this.setTags(tags)
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"outDir": "../../build/cjs"
|
||||
"moduleResolution": "Node",
|
||||
"declarationDir": "../../dist/cjs"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["es2020", "dom"],
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
"declarationDir": "../../dist/lib",
|
||||
},
|
||||
"references": [{ "path": "../common" }]
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +95,29 @@ export function canAccessIframe(iframe: HTMLIFrameElement) {
|
|||
}
|
||||
}
|
||||
|
||||
export function canAccessTarget(target: EventTarget): boolean {
|
||||
try {
|
||||
if (target instanceof HTMLIFrameElement) {
|
||||
void target.contentDocument
|
||||
} else if (target instanceof Window) {
|
||||
void target.document
|
||||
} else if (target instanceof Document) {
|
||||
void target.defaultView
|
||||
} else if ('nodeType' in target) {
|
||||
void (target as Node).nodeType
|
||||
} else if ('addEventListener' in target) {
|
||||
void (target as EventTarget).addEventListener
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'SecurityError') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function dec2hex(dec: number) {
|
||||
return dec.toString(16).padStart(2, '0')
|
||||
}
|
||||
|
|
@ -132,9 +155,13 @@ export function ngSafeBrowserMethod(method: string): string {
|
|||
: method
|
||||
}
|
||||
|
||||
export function createMutationObserver(cb: MutationCallback) {
|
||||
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
|
||||
return new window[mObserver](cb)
|
||||
export function createMutationObserver(cb: MutationCallback, forceNgOff?: boolean) {
|
||||
if (!forceNgOff) {
|
||||
const mObserver = ngSafeBrowserMethod('MutationObserver') as 'MutationObserver'
|
||||
return new window[mObserver](cb)
|
||||
} else {
|
||||
return new MutationObserver(cb)
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventListener(
|
||||
|
|
@ -142,15 +169,31 @@ export function createEventListener(
|
|||
event: string,
|
||||
cb: EventListenerOrEventListenerObject,
|
||||
capture?: boolean,
|
||||
forceNgOff?: boolean,
|
||||
) {
|
||||
const safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
|
||||
// we need to check if target is crossorigin frame or no and if we can access it
|
||||
if (!canAccessTarget(target)) {
|
||||
return
|
||||
}
|
||||
let safeAddEventListener = 'addEventListener' as unknown as 'addEventListener'
|
||||
if (!forceNgOff) {
|
||||
safeAddEventListener = ngSafeBrowserMethod('addEventListener') as 'addEventListener'
|
||||
}
|
||||
try {
|
||||
target[safeAddEventListener](event, cb, capture)
|
||||
// parent has angular, but child frame don't
|
||||
if (target[safeAddEventListener]) {
|
||||
target[safeAddEventListener](event, cb, capture)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
target.addEventListener(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,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -160,17 +203,29 @@ export function deleteEventListener(
|
|||
event: string,
|
||||
cb: EventListenerOrEventListenerObject,
|
||||
capture?: boolean,
|
||||
forceNgOff?: boolean,
|
||||
) {
|
||||
const safeRemoveEventListener = ngSafeBrowserMethod(
|
||||
'removeEventListener',
|
||||
) as 'removeEventListener'
|
||||
if (!canAccessTarget(target)) {
|
||||
return
|
||||
}
|
||||
let safeRemoveEventListener = 'removeEventListener' as unknown as 'removeEventListener'
|
||||
if (!forceNgOff) {
|
||||
safeRemoveEventListener = ngSafeBrowserMethod('removeEventListener') as 'removeEventListener'
|
||||
}
|
||||
try {
|
||||
target[safeRemoveEventListener](event, cb, capture)
|
||||
if (target[safeRemoveEventListener]) {
|
||||
target[safeRemoveEventListener](event, cb, capture)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
target.removeEventListener(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,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ describe('AttributeSender', () => {
|
|||
appMock = {
|
||||
send: (...args: any[]) => args,
|
||||
}
|
||||
attributeSender = new AttributeSender(appMock, false)
|
||||
attributeSender = new AttributeSender({
|
||||
app: appMock,
|
||||
isDictDisabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import FeatureFlags, { FetchPersistFlagsData, IFeatureFlag } from '../main/modules/FeatureFlags'
|
||||
import FeatureFlags, { FetchPersistFlagsData, IFeatureFlag } from '../main/modules/featureFlags'
|
||||
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
|
||||
|
||||
jest.mock('../main/app/index.js')
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ describe('Nodes', () => {
|
|||
const mockCallback = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
nodes = new Nodes(nodeId)
|
||||
nodes = new Nodes({
|
||||
node_id: nodeId,
|
||||
forceNgOff: false,
|
||||
})
|
||||
mockCallback.mockClear()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ describe('Sanitizer', () => {
|
|||
getID: (el: { mockId: number }) => el.mockId,
|
||||
},
|
||||
}
|
||||
// @ts-expect-error
|
||||
sanitizer = new Sanitizer(app, options)
|
||||
sanitizer = new Sanitizer({
|
||||
// @ts-expect-error
|
||||
app,
|
||||
options,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -78,7 +81,7 @@ describe('Sanitizer', () => {
|
|||
}
|
||||
|
||||
// @ts-expect-error
|
||||
sanitizer = new Sanitizer(app, options)
|
||||
sanitizer = new Sanitizer({ app, options })
|
||||
|
||||
const spanNode = document.createElement('span')
|
||||
const divNode = document.createElement('div')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { jest, test, describe, beforeEach, afterEach, expect } from '@jest/globals'
|
||||
import Session from '../main/app/Session'
|
||||
import Session from '../main/app/session'
|
||||
import App from '../main/app/index.js'
|
||||
import { generateRandomId } from '../main/utils.js'
|
||||
|
||||
|
|
@ -34,7 +34,10 @@ describe('Session', () => {
|
|||
// @ts-ignore
|
||||
generateRandomId.mockReturnValue('random_id')
|
||||
|
||||
session = new Session(mockApp as unknown as App, mockOptions)
|
||||
session = new Session({
|
||||
app: mockApp as App,
|
||||
options: mockOptions,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import TagWatcher, { WATCHED_TAGS_KEY } from '../main/modules/TagWatcher'
|
||||
import TagWatcher, { WATCHED_TAGS_KEY } from '../main/modules/tagWatcher'
|
||||
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
|
||||
|
||||
const getMockSaved = () => '[{"id":1,"selector":"div"},{"id":2,"selector":"span"}]'
|
||||
describe('TagWatcher', () => {
|
||||
let sessionStorageMock: Storage
|
||||
const sessionStorageMock = {
|
||||
getItem: getMockSaved,
|
||||
setItem: jest.fn(),
|
||||
} as unknown as Storage
|
||||
let errLogMock: (args: any[]) => void
|
||||
const onTag = jest.fn()
|
||||
let mockObserve: Function
|
||||
|
|
@ -10,11 +14,6 @@ describe('TagWatcher', () => {
|
|||
let mockDisconnect: Function
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorageMock = {
|
||||
// @ts-ignore
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
}
|
||||
errLogMock = jest.fn()
|
||||
mockObserve = jest.fn()
|
||||
mockUnobserve = jest.fn()
|
||||
|
|
@ -46,11 +45,9 @@ describe('TagWatcher', () => {
|
|||
}
|
||||
|
||||
test('constructor initializes with tags from sessionStorage', () => {
|
||||
// @ts-ignore
|
||||
sessionStorageMock.getItem.mockReturnValue(
|
||||
'[{"id":1,"selector":"div"},{"id":2,"selector":"span"}]',
|
||||
)
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
const watcher = new TagWatcher({
|
||||
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
|
||||
})
|
||||
expect(watcher.tags).toEqual([
|
||||
{ id: 1, selector: 'div' },
|
||||
{ id: 2, selector: 'span' },
|
||||
|
|
@ -73,7 +70,9 @@ describe('TagWatcher', () => {
|
|||
}),
|
||||
}),
|
||||
)
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
const watcher = new TagWatcher({
|
||||
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
|
||||
})
|
||||
await watcher.fetchTags('https://localhost.com', '123')
|
||||
expect(watcher.tags).toEqual([
|
||||
{ id: 1, selector: 'div' },
|
||||
|
|
@ -87,7 +86,9 @@ describe('TagWatcher', () => {
|
|||
})
|
||||
|
||||
test('setTags sets intervals for each tag', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
const watcher = new TagWatcher({
|
||||
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
|
||||
})
|
||||
watcher.setTags([
|
||||
{ id: 1, selector: 'div' },
|
||||
{ id: 2, selector: 'p' },
|
||||
|
|
@ -98,7 +99,9 @@ describe('TagWatcher', () => {
|
|||
})
|
||||
|
||||
test('onTagRendered sends messages', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
const watcher = new TagWatcher({
|
||||
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
|
||||
})
|
||||
watcher.setTags([{ id: 1, selector: 'div' }])
|
||||
// @ts-ignore
|
||||
document.querySelectorAll.mockReturnValue([{ __or_watcher_tagname: 'div' }]) // Mock a found element
|
||||
|
|
@ -109,7 +112,9 @@ describe('TagWatcher', () => {
|
|||
})
|
||||
|
||||
test('clear method clears all intervals and resets tags', () => {
|
||||
const watcher = new TagWatcher(sessionStorageMock, errLogMock, onTag)
|
||||
const watcher = new TagWatcher({
|
||||
sessionStorage: sessionStorageMock, errLog: errLogMock, onTag
|
||||
})
|
||||
watcher.setTags([
|
||||
{ id: 1, selector: 'div' },
|
||||
{ id: 2, selector: 'p' },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es6"]
|
||||
"lib": ["ES2020", "webworker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"target": "es2016",
|
||||
"preserveConstEnums": false,
|
||||
"declaration": false
|
||||
},
|
||||
"references": [{ "path": "../common" }]
|
||||
"references": [{ "path": "../../common" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "build",
|
||||
"declaration": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"target": "es2020",
|
||||
"module": "es6",
|
||||
"moduleResolution": "node",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
"exclude": ["**/*.test.ts"],
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue