tracker: fix maintainer doc node stability and build process

This commit is contained in:
nick-delirium 2024-10-21 14:03:50 +02:00
parent 70deccdcfc
commit e7afd66820
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
18 changed files with 159 additions and 91 deletions

View file

@ -251,4 +251,8 @@ export default class APIClient {
this.init.method = 'PATCH';
return this.fetch(path, params, 'PATCH');
}
forceSiteId = (siteId: string) => {
this.siteId = siteId;
}
}

View file

@ -24,6 +24,15 @@ function isStyleVElement(vElem: VElement): vElem is VElement & { node: StyleElem
return vElem.tagName.toLowerCase() === "style"
}
function setupWindowLogging(vTexts: Map<number, VText>, vElements: Map<number, VElement>, olVRoots: Map<number, OnloadVRoot>) {
// @ts-ignore
window.checkVElements = () => vElements
// @ts-ignore
window.checkVTexts = () => vTexts
// @ts-ignore
window.checkVRoots = () => olVRoots
}
const IGNORED_ATTRS = [ "autocomplete" ]
const ATTR_NAME_REGEXP = /([^\t\n\f \/>"'=]+)/
@ -56,6 +65,11 @@ export default class DOMManager extends ListWalker<Message> {
super()
this.selectionManager = new SelectionManager(this.vElements, screen)
this.stylesManager = new StylesManager(screen, setCssLoading)
setupWindowLogging(
this.vTexts,
this.vElements,
this.olVRoots,
)
}
setStringDict(stringDict: Record<number,string>) {
@ -216,7 +230,7 @@ export default class DOMManager extends ListWalker<Message> {
}
case MType.CreateElementNode: {
// if (msg.tag.toLowerCase() === 'canvas') msg.tag = 'video'
const vElem = new VElement(msg.tag, msg.svg, msg.index)
const vElem = new VElement(msg.tag, msg.svg, msg.index, msg.id)
if (['STYLE', 'style', 'LINK'].includes(msg.tag)) {
vElem.prioritized = true
}
@ -296,7 +310,7 @@ export default class DOMManager extends ListWalker<Message> {
return
}
/** @deprecated
/** @deprecated
* since 4.0.2 in favor of AdoptedSsInsertRule/DeleteRule + AdoptedSsAddOwner as a common case for StyleSheets
*/
case MType.CssInsertRule: {
@ -422,11 +436,11 @@ export default class DOMManager extends ListWalker<Message> {
}
/**
* Moves and applies all the messages from the current (or from the beginning, if t < current.time)
* Moves and applies all the messages from the current (or from the beginning, if t < current.time)
* to the one with msg[time] >= `t`
*
*
* This function autoresets pointer if necessary (better name?)
*
*
* @returns Promise that fulfills when necessary changes get applied
* (the async part exists mostly due to styles loading)
*/

View file

@ -144,12 +144,14 @@ export class VElement extends VParent<Element> {
parentNode: VParent | null = null /** Should be modified only by he parent itself */
private newAttributes: Map<string, string | false> = new Map()
constructor(readonly tagName: string, readonly isSVG = false, public readonly index: number) { super() }
constructor(readonly tagName: string, readonly isSVG = false, public readonly index: number, private readonly nodeId: number) { super() }
protected createNode() {
try {
return this.isSVG
const element = this.isSVG
? document.createElementNS('http://www.w3.org/2000/svg', this.tagName)
: document.createElement(this.tagName)
element.dataset['openreplayId'] = this.nodeId.toString()
return element
} catch (e) {
console.error('Openreplay: Player received invalid html tag', this.tagName, e)
return document.createElement(this.tagName.replace(/[^a-z]/gi, ''))

View file

@ -1,80 +1,89 @@
import APIClient from 'App/api_client';
const ALLOWED_404 = "No-file-and-this-is-ok"
const NO_BACKUP_FILE = "No-efs-file"
export const NO_URLS = 'No-urls-provided'
const ALLOWED_404 = 'No-file-and-this-is-ok';
const NO_BACKUP_FILE = 'No-efs-file';
export const NO_URLS = 'No-urls-provided';
export async function loadFiles(
urls: string[],
onData: (data: Uint8Array) => void,
canSkip: boolean = false,
canSkip: boolean = false
): Promise<void> {
if (!urls.length) {
throw NO_URLS
throw NO_URLS;
}
try {
for (let url of urls) {
await loadFile(url, onData, urls.length > 1 ? url !== urls[0] : canSkip)
await loadFile(url, onData, urls.length > 1 ? url !== urls[0] : canSkip);
}
return Promise.resolve()
return Promise.resolve();
} catch (e) {
return Promise.reject(e)
return Promise.reject(e);
}
}
export async function loadFile(
url: string,
onData: (data: Uint8Array) => void,
canSkip: boolean = false,
canSkip: boolean = false
): Promise<void> {
return window.fetch(url)
.then(response => processAPIStreamResponse(response, canSkip))
.then(data => onData(data))
.catch(e => {
return window
.fetch(url)
.then((response) => processAPIStreamResponse(response, canSkip))
.then((data) => onData(data))
.catch((e) => {
if (e === ALLOWED_404) {
return;
} else {
throw e
throw e;
}
})
});
}
export async function requestEFSDom(sessionId: string) {
return await requestEFSMobFile(sessionId + "/dom.mob")
return await requestEFSMobFile(sessionId + '/dom.mob');
}
export async function requestEFSDevtools(sessionId: string) {
return await requestEFSMobFile(sessionId + "/devtools.mob")
return await requestEFSMobFile(sessionId + '/devtools.mob');
}
export async function requestTarball(url: string) {
const res = await window.fetch(url)
const res = await window.fetch(url);
if (res.ok) {
const buf = await res.arrayBuffer()
return new Uint8Array(buf)
const buf = await res.arrayBuffer();
return new Uint8Array(buf);
} else {
throw new Error(res.status.toString())
throw new Error(res.status.toString());
}
}
async function requestEFSMobFile(filename: string) {
const api = new APIClient()
const res = await api.fetch('/unprocessed/' + filename)
if (res.status >= 400) {
throw NO_BACKUP_FILE
const api = new APIClient();
const siteId = document.location.href.match(
/https:\/\/[a-z.-]+\/([a-z0-9]+)\/[a-z-]+\/[0-9]+/
)?.[1];
if (siteId) {
api.forceSiteId(siteId);
api.setSiteIdCheck(() => siteId);
}
return await processAPIStreamResponse(res, false)
const res = await api.fetch('/unprocessed/' + filename);
if (res.status >= 400) {
throw NO_BACKUP_FILE;
}
return await processAPIStreamResponse(res, false);
}
const processAPIStreamResponse = (response: Response, skippable: boolean) => {
return new Promise<ArrayBuffer>((res, rej) => {
if (response.status === 404 && skippable) {
return rej(ALLOWED_404)
return rej(ALLOWED_404);
}
if (response.status >= 400) {
return rej(`Bad file status code ${response.status}. Url: ${response.url}`)
return rej(
`Bad file status code ${response.status}. Url: ${response.url}`
);
}
res(response.arrayBuffer())
}).then(async buf => new Uint8Array(buf))
}
res(response.arrayBuffer());
}).then(async (buf) => new Uint8Array(buf));
};

View file

@ -2,6 +2,10 @@
- new webvitals messages source
## 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)

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "15.0.0",
"version": "15.0.1-32",
"keywords": [
"logging",
"replay"
@ -50,19 +50,19 @@
"@rollup/plugin-replace": "^6.0.1",
"@rollup/plugin-terser": "0.4.4",
"@rollup/plugin-typescript": "^12.1.1",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"@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",
"semver": "^6.3.0",
"ts-jest": "^29.0.3",
"tslib": "^2.8.0",
"typescript": "^5.6.3"
},
"dependencies": {
@ -76,13 +76,5 @@
"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.0"
"packageManager": "yarn@4.5.1"
}

View file

@ -74,6 +74,8 @@ async function buildWebWorker() {
inlineDynamicImports: true,
})
const webWorkerCode = output[0].code
console.log('webworker done!')
return webWorkerCode.replace(/"/g, '\\"').replace(/\n/g, '')
console.log('webworker done!', output.length)
return webWorkerCode
}

View file

@ -74,7 +74,8 @@ export declare const enum Type {
TagTrigger = 120,
Redux = 121,
SetPageLocation = 122,
GraphQL = 123
GraphQL = 123,
WebVitals = 124
}
export type Timestamp = [
Type.Timestamp,
@ -544,5 +545,10 @@ export type GraphQL = [
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 type WebVitals = [
Type.WebVitals,
string,
string
];
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 | WebVitals;
export default Message;

File diff suppressed because one or more lines are too long

View file

@ -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'

View file

@ -67,6 +67,11 @@ interface OnStartInfo {
userUUID: string
}
/**
* this value is injected during build time via rollup
* */
// @ts-ignore
const workerBodyFn = WEBWORKER_BODY
const CANCELED = 'canceled' as const
const uxtStorageKey = 'or_uxt_active'
const bufferStorageKey = 'or_buffer_1'
@ -657,7 +662,7 @@ export default class App {
},
this.options.crossdomain?.parentDomain ?? '*',
)
console.log('Trying to signal to parent, attempt:', retries + 1)
this.debug.info('Trying to signal to parent, attempt:', retries + 1)
retries++
}
@ -693,6 +698,7 @@ export default class App {
this.pageFrames = pageIframes
targetFrame = pageIframes.find((frame) => frame.contentWindow === source)
}
if (!targetFrame) {
return null
}
@ -721,7 +727,7 @@ export default class App {
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)

View file

@ -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

View file

@ -30,30 +30,32 @@ function processMapInBatches(
processNextBatch()
}
function isNodeStillActive(node: Node): boolean {
function isNodeStillActive(node: Node): [isCon: boolean, reason: string] {
try {
if (!node.isConnected) {
return false
return [false, 'not connected']
}
const nodeIsDocument = node.nodeType === Node.DOCUMENT_NODE
const nodeWindow = nodeIsDocument
? (node as Document).defaultView
: node.ownerDocument?.defaultView
const nodeWindow = node.ownerDocument?.defaultView
const ownerDoc = nodeIsDocument ? (node as Document) : node.ownerDocument
if (!nodeWindow) {
return false
return [false, 'no window']
}
if (nodeWindow.closed) {
return false
return [false, 'window closed']
}
if (!node.ownerDocument.documentElement.isConnected) {
return false
if (!ownerDoc?.documentElement.isConnected) {
return [false, 'documentElement not connected']
}
return true
return [true, 'ok']
} catch (e) {
console.error('Error checking node activity:', e)
return false
return [false, e]
}
}
@ -102,7 +104,8 @@ class Maintainer {
this.interval = setInterval(() => {
processMapInBatches(this.nodes, this.options.batchSize, (node) => {
if (!isNodeStillActive(node)) {
const isActive = isNodeStillActive(node)[0]
if (!isActive) {
this.unregisterNode(node)
}
})

View file

@ -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,6 +16,7 @@ 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))
})
}
@ -28,4 +30,11 @@ export default class IFrameObserver extends Observer {
this.app.send(CreateIFrameDocument(rootNodeId, docID))
})
}
disconnect() {
if (this.docId !== undefined) {
this.app.send(RemoveNode(this.docId))
}
super.disconnect()
}
}

View file

@ -32,7 +32,6 @@ export default class TopObserver extends Observer {
},
params.options,
)
// IFrames
this.app.nodes.attachNodeCallback((node) => {
if (
@ -64,25 +63,34 @@ export default class TopObserver extends Observer {
return this.iframeOffsets.getDocumentOffset(doc)
}
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 || !canAccessIframe(iframe)) return
const currentWin = iframe.contentWindow
const currentDoc = iframe.contentDocument
if (currentDoc && currentDoc !== doc) {
const observer = new IFrameObserver(this.app)
this.iframeObservers.set(iframe, 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
@ -91,13 +99,13 @@ 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()
}
@ -118,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>
@ -155,8 +162,11 @@ export default class TopObserver extends Observer {
disconnect() {
this.iframeOffsets.clear()
Element.prototype.attachShadow = attachShadowNativeFn
this.iframeObserversArr.forEach((observer) => observer.disconnect())
this.iframeObserversArr = []
this.iframeObservers = new WeakMap()
this.shadowRootObservers = new WeakMap()
this.docObservers = new WeakMap()
super.disconnect()
}
}

View file

@ -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

View file

@ -1,12 +1,12 @@
{
"extends": "../../tsconfig-base.json",
"compilerOptions": {
"lib": ["es6", "webworker"],
"lib": ["ES2020", "webworker"],
"module": "ESNext",
"moduleResolution": "Node",
"target": "es2016",
"preserveConstEnums": false,
"declaration": false
},
"references": [{ "path": "../common" }]
"references": [{ "path": "../../common" }]
}