Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
nick-delirium
08084ec0bc fix(tracker): fix blob generation 2024-01-12 10:11:38 +01:00
nick-delirium
ca7dd4ae46 fix(tracker): expose canvas tracking restart method, remove context creation in assist 2024-01-11 16:53:44 +01:00
nick-delirium
972b15590b fix(tracker): scan node tree for canvas on start 2024-01-11 15:06:45 +01:00
nick-delirium
58d7ac18c9 fix(tracker): 11.0.4 2024-01-09 13:13:26 +01:00
nick-delirium
3bfdd9939b fix(tracker): add more security to canvas el capture 2024-01-09 13:11:29 +01:00
nick-delirium
8f8268d0de fix(tracker): fix assist tarcker v 2024-01-02 17:24:08 +01:00
nick-delirium
8581b6e0e9 feat(tracker): 11.0.3 2024-01-02 16:29:53 +01:00
nick-delirium
157a364313 chore(tracker): changelogs 2024-01-02 10:37:56 +01:00
nick-delirium
f386ddea11 fix(tracker): rewrite logs to use tracker logger instead of plain console 2024-01-02 10:37:50 +01:00
nick-delirium
777e6c8aba fix(tracker): fixes #1813, fix ORSC function for xhr proxy 2024-01-02 10:37:39 +01:00
18 changed files with 134 additions and 71 deletions

View file

@ -1,3 +1,7 @@
## 7.0.3
- small fix for canvas context tracking
## 7.0.1 ## 7.0.1
- mark live sessions with ux test active - mark live sessions with ux test active

Binary file not shown.

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker-assist", "name": "@openreplay/tracker-assist",
"description": "Tracker plugin for screen assistance through the WebRTC", "description": "Tracker plugin for screen assistance through the WebRTC",
"version": "7.0.1", "version": "7.0.3",
"keywords": [ "keywords": [
"WebRTC", "WebRTC",
"assistance", "assistance",
@ -34,7 +34,7 @@
"socket.io-client": "^4.7.2" "socket.io-client": "^4.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@openreplay/tracker": "^11.0.1" "@openreplay/tracker": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@openreplay/tracker": "file:../tracker", "@openreplay/tracker": "file:../tracker",

View file

@ -535,7 +535,7 @@ export default class Assist {
} }
if (!callUI) { if (!callUI) {
callUI = new CallWindow(console.log, this.options.callUITemplate) callUI = new CallWindow(app.debug.error, this.options.callUITemplate)
callUI.setVideoToggleCallback(updateVideoFeed) callUI.setVideoToggleCallback(updateVideoFeed)
} }
callUI.showControls(initiateCallEnd) callUI.showControls(initiateCallEnd)
@ -583,7 +583,6 @@ export default class Assist {
sessionStorage.setItem(this.options.session_calling_peer_key, JSON.stringify(callingPeerIds)) sessionStorage.setItem(this.options.session_calling_peer_key, JSON.stringify(callingPeerIds))
this.emit('UPDATE_SESSION', { agentIds: callingPeerIds, isCallActive: true, }) this.emit('UPDATE_SESSION', { agentIds: callingPeerIds, isCallActive: true, })
}).catch(reason => { // in case of Confirm.remove() without user answer (not a error) }).catch(reason => { // in case of Confirm.remove() without user answer (not a error)
console.log(reason)
app.debug.log(reason) app.debug.log(reason)
}) })
}) })
@ -615,6 +614,7 @@ export default class Assist {
} }
}) })
}, },
app.debug.error,
) )
this.canvasMap.set(id, canvasHandler) this.canvasMap.set(id, canvasHandler)
} }

View file

@ -42,7 +42,7 @@ export default class CallWindow {
const doc = iframe.contentDocument const doc = iframe.contentDocument
if (!doc) { if (!doc) {
console.error('OpenReplay: CallWindow iframe document is not reachable.') logError('OpenReplay: CallWindow iframe document is not reachable.')
return return
} }

View file

@ -6,9 +6,9 @@ export default class CanvasRecorder {
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
private readonly canvasId: number, private readonly canvasId: number,
private readonly fps: number, private readonly fps: number,
private readonly onStream: (stream: MediaStream) => void private readonly onStream: (stream: MediaStream) => void,
private readonly logError: (...args: any[]) => void,
) { ) {
this.canvas.getContext('2d', { alpha: true, })
const stream = this.canvas.captureStream(this.fps) const stream = this.canvas.captureStream(this.fps)
this.emitStream(stream) this.emitStream(stream)
} }
@ -39,7 +39,7 @@ export default class CanvasRecorder {
void video.play() void video.play()
video.addEventListener('error', (e) => { video.addEventListener('error', (e) => {
console.error('Video error:', e) this.logError('Video error:', e)
}) })
} }
@ -50,7 +50,7 @@ export default class CanvasRecorder {
if (this.stream) { if (this.stream) {
this.onStream(this.stream) this.onStream(this.stream)
} else { } else {
console.error('no stream for canvas', this.canvasId) this.logError('no stream for canvas', this.canvasId)
} }
} }

View file

@ -8,17 +8,16 @@ import Assist from './Assist.js'
export default function(opts?: Partial<Options>) { export default function(opts?: Partial<Options>) {
return function(app: App | null, appOptions: { __DISABLE_SECURE_MODE?: boolean } = {}) { return function(app: App | null, appOptions: { __DISABLE_SECURE_MODE?: boolean } = {}) {
// @ts-ignore // @ts-ignore
if (app === null || !navigator?.mediaDevices?.getUserMedia) { // 93.04% browsers if (app === null || !navigator?.mediaDevices?.getUserMedia) {
return return
} }
if (!app.checkRequiredVersion || !app.checkRequiredVersion('REQUIRED_TRACKER_VERSION')) { 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') console.warn('OpenReplay Assist: couldn\'t load. The minimum required version of @openreplay/tracker@REQUIRED_TRACKER_VERSION is not met')
return return
} }
app.notify.log('OpenReplay Assist initializing.')
const assist = new Assist(app, opts, appOptions.__DISABLE_SECURE_MODE) const assist = new Assist(app, opts, appOptions.__DISABLE_SECURE_MODE)
app.debug.log(assist) app.debug.log(assist)
return assist return assist
} }
} }

View file

@ -1 +1 @@
export const pkgVersion = '7.0.1' export const pkgVersion = '7.0.3'

View file

@ -1,4 +1,22 @@
# 11.0.1 # 11.0.6
- fix blob generation for canvas capture (Cannot read properties of null (reading '1'))
# 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
- some additional security for canvas capture (check if canvas el itself is obscured/ignored)
# 11.0.3
- move all logs under internal debugger
- fix for XHR proxy ORSC 'abort' state
# 11.0.1 & 11.0.2
- minor fixes and refactoring - minor fixes and refactoring

View file

@ -1,7 +1,7 @@
{ {
"name": "@openreplay/tracker", "name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package", "description": "The OpenReplay tracker main package",
"version": "11.0.2", "version": "11.0.6",
"keywords": [ "keywords": [
"logging", "logging",
"replay" "replay"

View file

@ -10,6 +10,7 @@ interface CanvasSnapshot {
interface Options { interface Options {
fps: number fps: number
quality: 'low' | 'medium' | 'high' quality: 'low' | 'medium' | 'high'
isDebug?: boolean
} }
class CanvasRecorder { class CanvasRecorder {
@ -25,47 +26,66 @@ class CanvasRecorder {
} }
startTracking() { startTracking() {
this.app.nodes.attachNodeCallback((node: Node): void => { setTimeout(() => {
const id = this.app.nodes.getID(node) this.app.nodes.scanTree(this.handleCanvasEl)
if (!id || !hasTag(node, 'canvas') || this.snapshots[id]) { this.app.nodes.attachNodeCallback((node: Node): void => {
return this.handleCanvasEl(node)
} })
const ts = this.app.timestamp() }, 500)
this.snapshots[id] = { }
images: [],
createdAt: ts, restartTracking = () => {
} this.clear()
const canvasMsg = CanvasNode(id.toString(), ts) this.app.nodes.scanTree(this.handleCanvasEl)
this.app.send(canvasMsg as Message) }
const int = setInterval(() => {
const cid = this.app.nodes.getID(node) handleCanvasEl = (node: Node) => {
const canvas = cid ? this.app.nodes.getNode(cid) : undefined const id = this.app.nodes.getID(node)
if (!canvas || !hasTag(canvas, 'canvas') || canvas !== node) { if (!id || !hasTag(node, 'canvas')) {
console.log('Canvas element not in sync') return
clearInterval(int) }
} else {
const snapshot = captureSnapshot(canvas, this.options.quality) const isIgnored = this.app.sanitizer.isObscured(id) || this.app.sanitizer.isHidden(id)
this.snapshots[id].images.push({ id: this.app.timestamp(), data: snapshot }) if (isIgnored || !hasTag(node, 'canvas') || this.snapshots[id]) {
if (this.snapshots[id].images.length > 9) { return
this.sendSnaps(this.snapshots[id].images, id, this.snapshots[id].createdAt) }
this.snapshots[id].images = [] const ts = this.app.timestamp()
} this.snapshots[id] = {
images: [],
createdAt: ts,
}
const canvasMsg = CanvasNode(id.toString(), ts)
this.app.send(canvasMsg as Message)
const int = setInterval(() => {
const cid = this.app.nodes.getID(node)
const canvas = cid ? this.app.nodes.getNode(cid) : undefined
if (!canvas || !hasTag(canvas, 'canvas') || canvas !== node) {
this.app.debug.log('Canvas element not in sync')
clearInterval(int)
} else {
const snapshot = captureSnapshot(canvas, this.options.quality)
this.snapshots[id].images.push({ id: this.app.timestamp(), data: snapshot })
if (this.snapshots[id].images.length > 9) {
this.sendSnaps(this.snapshots[id].images, id, this.snapshots[id].createdAt)
this.snapshots[id].images = []
} }
}, this.interval) }
this.intervals.push(int) }, this.interval)
}) this.intervals.push(int)
} }
sendSnaps(images: { data: string; id: number }[], canvasId: number, createdAt: number) { sendSnaps(images: { data: string; id: number }[], canvasId: number, createdAt: number) {
if (Object.keys(this.snapshots).length === 0) { if (Object.keys(this.snapshots).length === 0) {
console.log(this.snapshots)
return return
} }
const formData = new FormData() const formData = new FormData()
images.forEach((snapshot) => { images.forEach((snapshot) => {
const blob = dataUrlToBlob(snapshot.data)[0] const blob = dataUrlToBlob(snapshot.data)
formData.append('snapshot', blob, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) if (!blob) return
// saveImageData(snapshot.data, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`) formData.append('snapshot', blob[0], `${createdAt}_${canvasId}_${snapshot.id}.jpeg`)
if (this.options.isDebug) {
saveImageData(snapshot.data, `${createdAt}_${canvasId}_${snapshot.id}.jpeg`)
}
}) })
fetch(this.app.options.ingestPoint + '/v1/web/images', { fetch(this.app.options.ingestPoint + '/v1/web/images', {
@ -75,16 +95,15 @@ class CanvasRecorder {
}, },
body: formData, body: formData,
}) })
.then((r) => { .then(() => {
console.log('done', r) return true
}) })
.catch((e) => { .catch((e) => {
console.error('error saving canvas', e) this.app.debug.error('error saving canvas', e)
}) })
} }
clear() { clear() {
console.log('cleaning up')
this.intervals.forEach((int) => clearInterval(int)) this.intervals.forEach((int) => clearInterval(int))
this.snapshots = {} this.snapshots = {}
} }
@ -101,10 +120,11 @@ function captureSnapshot(canvas: HTMLCanvasElement, quality: 'low' | 'medium' |
return canvas.toDataURL(imageFormat, qualityInt[quality]) return canvas.toDataURL(imageFormat, qualityInt[quality])
} }
function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] { function dataUrlToBlob(dataUrl: string): [Blob, Uint8Array] | null {
const [header, base64] = dataUrl.split(',') const [header, base64] = dataUrl.split(',')
// @ts-ignore const encParts = header.match(/:(.*?);/)
const mime = header.match(/:(.*?);/)[1] if (!encParts) return null
const mime = encParts[1]
const blobStr = atob(base64) const blobStr = atob(base64)
let n = blobStr.length let n = blobStr.length
const u8arr = new Uint8Array(n) const u8arr = new Uint8Array(n)

View file

@ -106,6 +106,7 @@ type AppOptions = {
__is_snippet: boolean __is_snippet: boolean
__debug_report_edp: string | null __debug_report_edp: string | null
__debug__?: LoggerOptions __debug__?: LoggerOptions
__save_canvas_locally?: boolean
localStorage: Storage | null localStorage: Storage | null
sessionStorage: Storage | null sessionStorage: Storage | null
forceSingleTab?: boolean forceSingleTab?: boolean
@ -183,6 +184,7 @@ export default class App {
verbose: false, verbose: false,
__is_snippet: false, __is_snippet: false,
__debug_report_edp: null, __debug_report_edp: null,
__save_canvas_locally: false,
localStorage: null, localStorage: null,
sessionStorage: null, sessionStorage: null,
disableStringDict: false, disableStringDict: false,
@ -236,9 +238,10 @@ export default class App {
this.stop(false) this.stop(false)
void this.start({}, true) void this.start({}, true)
} else if (data === 'not_init') { } else if (data === 'not_init') {
console.warn('WebWorker: writer not initialised. Restarting tracker') this.debug.warn('OR WebWorker: writer not initialised. Restarting tracker')
} else if (data.type === 'failure') { } else if (data.type === 'failure') {
this.stop(false) this.stop(false)
this.debug.error('worker_failed', data.reason)
this._debug('worker_failed', data.reason) this._debug('worker_failed', data.reason)
} else if (data.type === 'compress') { } else if (data.type === 'compress') {
const batch = data.batch const batch = data.batch
@ -246,7 +249,7 @@ export default class App {
if (batchSize > this.compressionThreshold) { if (batchSize > this.compressionThreshold) {
gzip(data.batch, { mtime: 0 }, (err, result) => { gzip(data.batch, { mtime: 0 }, (err, result) => {
if (err) { if (err) {
console.error('Openreplay compression error:', err) this.debug.error('Openreplay compression error:', err)
this.stop(false) this.stop(false)
if (this.restartAttempts < 3) { if (this.restartAttempts < 3) {
this.restartAttempts += 1 this.restartAttempts += 1
@ -595,7 +598,7 @@ export default class App {
const sessionToken = this.session.getSessionToken() const sessionToken = this.session.getSessionToken()
const isNewSession = needNewSessionID || !sessionToken const isNewSession = needNewSessionID || !sessionToken
console.log( this.debug.log(
'OpenReplay: starting session; need new session id?', 'OpenReplay: starting session; need new session id?',
needNewSessionID, needNewSessionID,
'session token: ', 'session token: ',
@ -682,7 +685,7 @@ export default class App {
projectID, projectID,
}) })
if (!isNewSession && token === sessionToken) { if (!isNewSession && token === sessionToken) {
console.log('continuing session on new tab', this.session.getTabId()) this.debug.log('continuing session on new tab', this.session.getTabId())
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.send(TabChange(this.session.getTabId())) this.send(TabChange(this.session.getTabId()))
} }
@ -701,17 +704,21 @@ export default class App {
this.compressionThreshold = compressionThreshold this.compressionThreshold = compressionThreshold
const onStartInfo = { sessionToken: token, userUUID, sessionID } const onStartInfo = { sessionToken: token, userUUID, sessionID }
if (canvasEnabled) {
this.canvasRecorder =
this.canvasRecorder ??
new CanvasRecorder(this, {
fps: canvasFPS,
quality: canvasQuality,
isDebug: this.options.__save_canvas_locally,
})
this.canvasRecorder.startTracking()
}
// TODO: start as early as possible (before receiving the token) // TODO: start as early as possible (before receiving the token)
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed) this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
this.observer.observe() this.observer.observe()
this.ticker.start() this.ticker.start()
if (canvasEnabled) {
this.canvasRecorder =
this.canvasRecorder ??
new CanvasRecorder(this, { fps: canvasFPS, quality: canvasQuality })
this.canvasRecorder.startTracking()
}
this.activityState = ActivityState.Active this.activityState = ActivityState.Active
this.notify.log('OpenReplay tracking started.') this.notify.log('OpenReplay tracking started.')
@ -766,6 +773,10 @@ export default class App {
}) })
} }
restartCanvasTracking = () => {
this.canvasRecorder?.restartTracking()
}
onUxtCb = [] onUxtCb = []
addOnUxtCb(cb: (id: number) => void) { addOnUxtCb(cb: (id: number) => void) {

View file

@ -16,6 +16,10 @@ export default class Nodes {
this.nodeCallbacks.push(nodeCallback) this.nodeCallbacks.push(nodeCallback)
} }
scanTree = (cb: (node: Node | void) => void) => {
this.nodes.forEach((node) => cb(node))
}
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) const id = this.getID(node)
if (id === undefined) { if (id === undefined) {

View file

@ -12,7 +12,7 @@ export default class IFrameObserver extends Observer {
this.observeRoot(doc, (docID) => { 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) //MBTODO: do not send if empty (send on load? it might be in-place iframe, like our replayer, which does not get loaded)
if (docID === undefined) { if (docID === undefined) {
console.log('OpenReplay: Iframe document not bound') this.app.debug.log('OpenReplay: Iframe document not bound')
return return
} }
this.app.send(CreateIFrameDocument(hostID, docID)) this.app.send(CreateIFrameDocument(hostID, docID))

View file

@ -10,7 +10,7 @@ export default class ShadowRootObserver extends Observer {
} // log } // log
this.observeRoot(shRoot, (rootID) => { this.observeRoot(shRoot, (rootID) => {
if (rootID === undefined) { if (rootID === undefined) {
console.log('OpenReplay: Shadow Root was not bound') this.app.debug.error('OpenReplay: Shadow Root was not bound')
return return
} }
this.app.send(CreateIFrameDocument(hostID, rootID)) this.app.send(CreateIFrameDocument(hostID, rootID))

View file

@ -223,6 +223,13 @@ export default class API {
return this.featureFlags.flags return this.featureFlags.flags
} }
public restartCanvasTracking = () => {
if (this.app === null) {
return
}
this.app.restartCanvasTracking()
}
use<T>(fn: (app: App | null, options?: Options) => T): T { use<T>(fn: (app: App | null, options?: Options) => T): T {
return fn(this.app, this.options) return fn(this.app, this.options)
} }

View file

@ -146,7 +146,7 @@ export class XHRProxyHandler<T extends XMLHttpRequest> implements ProxyHandler<T
protected setOnReadyStateChange(target: T, key: string, orscFunction: (args: any[]) => any) { protected setOnReadyStateChange(target: T, key: string, orscFunction: (args: any[]) => any) {
return Reflect.set(target, key, (...args: any[]) => { return Reflect.set(target, key, (...args: any[]) => {
this.onReadyStateChange() this.onReadyStateChange()
orscFunction.apply(target, args) orscFunction?.apply(target, args)
}) })
} }

View file

@ -115,7 +115,7 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
if (data.type === 'compressed') { if (data.type === 'compressed') {
if (!sender) { if (!sender) {
console.debug('WebWorker: sender not initialised. Compressed batch.') console.debug('OR WebWorker: sender not initialised. Compressed batch.')
initiateRestart() initiateRestart()
return return
} }
@ -123,7 +123,7 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
} }
if (data.type === 'uncompressed') { if (data.type === 'uncompressed') {
if (!sender) { if (!sender) {
console.debug('WebWorker: sender not initialised. Uncompressed batch.') console.debug('OR WebWorker: sender not initialised. Uncompressed batch.')
initiateRestart() initiateRestart()
return return
} }
@ -163,13 +163,13 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
if (data.type === 'auth') { if (data.type === 'auth') {
if (!sender) { if (!sender) {
console.debug('WebWorker: sender not initialised. Received auth.') console.debug('OR WebWorker: sender not initialised. Received auth.')
initiateRestart() initiateRestart()
return return
} }
if (!writer) { if (!writer) {
console.debug('WebWorker: writer not initialised. Received auth.') console.debug('OR WebWorker: writer not initialised. Received auth.')
initiateRestart() initiateRestart()
return return
} }