1778 lines
54 KiB
TypeScript
1778 lines
54 KiB
TypeScript
import { gzip } from 'fflate'
|
|
|
|
import type {
|
|
FromWorkerData,
|
|
Options as WebworkerOptions,
|
|
ToWorkerData,
|
|
} from '../../common/interaction.js'
|
|
import AttributeSender from '../modules/attributeSender.js'
|
|
import ConditionsManager from '../modules/conditionsManager.js'
|
|
import FeatureFlags from '../modules/featureFlags.js'
|
|
import type { Options as NetworkOptions } from '../modules/network.js'
|
|
import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js'
|
|
import TagWatcher from '../modules/tagWatcher.js'
|
|
import UserTestManager from '../modules/userTesting/index.js'
|
|
import {
|
|
adjustTimeOrigin,
|
|
createEventListener,
|
|
deleteEventListener,
|
|
now,
|
|
requestIdleCb,
|
|
simpleMerge,
|
|
} from '../utils.js'
|
|
import CanvasRecorder from './canvas.js'
|
|
import Logger, { ILogLevel, LogLevel } from './logger.js'
|
|
import Message, {
|
|
Metadata,
|
|
TabChange,
|
|
TabData,
|
|
TagTrigger,
|
|
Timestamp,
|
|
Type as MType,
|
|
UserID,
|
|
WSChannel,
|
|
} from './messages.gen.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'
|
|
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
|
|
}
|
|
|
|
// TODO: Unify and clearly describe options logic
|
|
export interface StartOptions {
|
|
userID?: string
|
|
metadata?: Record<string, string>
|
|
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 {
|
|
sessionID: string
|
|
sessionToken: string
|
|
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'
|
|
|
|
type SuccessfulStart = OnStartInfo & {
|
|
success: true
|
|
}
|
|
type UnsuccessfulStart = {
|
|
reason: typeof CANCELED | string
|
|
success: false
|
|
}
|
|
|
|
type RickRoll = {
|
|
source: string
|
|
context: string
|
|
} & (
|
|
| {
|
|
line: 'never-gonna-give-you-up'
|
|
}
|
|
| {
|
|
line: 'never-gonna-let-you-down'
|
|
token: string
|
|
}
|
|
| {
|
|
line: 'never-gonna-run-around-and-desert-you'
|
|
token: string
|
|
}
|
|
)
|
|
|
|
const UnsuccessfulStart = (reason: string): UnsuccessfulStart => ({ reason, success: false })
|
|
const SuccessfulStart = (body: OnStartInfo): SuccessfulStart => ({ ...body, success: true })
|
|
export type StartPromiseReturn = SuccessfulStart | UnsuccessfulStart
|
|
|
|
type StartCallback = (i: OnStartInfo) => void
|
|
type CommitCallback = (messages: Array<Message>) => void
|
|
|
|
enum ActivityState {
|
|
NotActive,
|
|
Starting,
|
|
Active,
|
|
ColdStart,
|
|
}
|
|
|
|
type AppOptions = {
|
|
revID: string
|
|
node_id: string
|
|
session_reset_key: string
|
|
session_token_key: string
|
|
session_pageno_key: string
|
|
session_tabid_key: string
|
|
local_uuid_key: string
|
|
ingestPoint: string
|
|
resourceBaseHref: string | null // resourceHref?
|
|
__is_snippet: boolean
|
|
__debug_report_edp: string | null
|
|
__debug__?: ILogLevel
|
|
/** @deprecated see canvas prop */
|
|
__save_canvas_locally?: boolean
|
|
/** @deprecated see canvas prop */
|
|
fixedCanvasScaling?: boolean
|
|
localStorage: Storage | null
|
|
sessionStorage: Storage | null
|
|
forceSingleTab?: boolean
|
|
/** Sometimes helps to prevent session breaking due to dict reset */
|
|
disableStringDict?: boolean
|
|
assistSocketHost?: string
|
|
/** @deprecated see canvas prop */
|
|
disableCanvas?: boolean
|
|
canvas: {
|
|
disableCanvas?: boolean
|
|
/**
|
|
* If you expect HI-DPI users mostly, this will render canvas
|
|
* in 1:1 pixel ratio
|
|
* */
|
|
fixedCanvasScaling?: boolean
|
|
__save_canvas_locally?: boolean
|
|
/**
|
|
* Use with care since it hijacks one frame each time it captures
|
|
* snapshot for every canvas
|
|
* */
|
|
useAnimationFrame?: boolean
|
|
/**
|
|
* Use webp unless it produces too big images
|
|
* @default webp
|
|
* */
|
|
fileExt?: 'webp' | 'png' | 'jpeg' | 'avif'
|
|
}
|
|
crossdomain?: {
|
|
/**
|
|
* @default false
|
|
* */
|
|
enabled?: boolean
|
|
/**
|
|
* used to send message up, will be '*' by default
|
|
* (check your CSP settings)
|
|
* @default '*'
|
|
* */
|
|
parentDomain?: string
|
|
}
|
|
|
|
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
|
|
|
|
export type Options = AppOptions & ObserverOptions & SanitizerOptions
|
|
|
|
// TODO: use backendHost only
|
|
export const DEFAULT_INGEST_POINT = 'https://api.openreplay.com/ingest'
|
|
|
|
function getTimezone() {
|
|
const offset = new Date().getTimezoneOffset() * -1
|
|
const sign = offset >= 0 ? '+' : '-'
|
|
const hours = Math.floor(Math.abs(offset) / 60)
|
|
const minutes = Math.abs(offset) % 60
|
|
return `UTC${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
}
|
|
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
|
|
|
const proto = {
|
|
// ask if there are any tabs alive
|
|
ask: 'never-gonna-give-you-up',
|
|
// response from another tab
|
|
resp: 'never-gonna-let-you-down',
|
|
// regenerating id (copied other tab)
|
|
reg: 'never-gonna-run-around-and-desert-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 {
|
|
readonly nodes: Nodes
|
|
readonly ticker: Ticker
|
|
readonly projectKey: string
|
|
readonly sanitizer: Sanitizer
|
|
readonly debug: Logger
|
|
readonly notify: Logger
|
|
readonly session: Session
|
|
readonly localStorage: Storage
|
|
readonly sessionStorage: Storage
|
|
private readonly messages: Array<Message> = []
|
|
/**
|
|
* we need 2 buffers, so we don't lose anything
|
|
* @read coldStart implementation
|
|
* */
|
|
private bufferedMessages1: Array<Message> = []
|
|
private readonly bufferedMessages2: Array<Message> = []
|
|
/* private */
|
|
readonly observer: Observer // non-private for attachContextCallback
|
|
private readonly startCallbacks: Array<StartCallback> = []
|
|
private readonly stopCallbacks: Array<() => any> = []
|
|
private readonly commitCallbacks: Array<CommitCallback> = []
|
|
public readonly options: AppOptions
|
|
public readonly networkOptions?: NetworkOptions
|
|
private readonly revID: string
|
|
private activityState: ActivityState = ActivityState.NotActive
|
|
private readonly version = 'TRACKER_VERSION' // TODO: version compatability check inside each plugin.
|
|
private worker?: TypedWorker
|
|
|
|
public attributeSender: AttributeSender
|
|
public featureFlags: FeatureFlags
|
|
public socketMode = false
|
|
private compressionThreshold = 24 * 1000
|
|
private readonly bc: BroadcastChannel | null = null
|
|
private readonly contextId
|
|
private canvasRecorder: CanvasRecorder | null = null
|
|
private uxtManager: UserTestManager
|
|
private conditionsManager: ConditionsManager | null = null
|
|
private readonly tagWatcher: TagWatcher
|
|
|
|
private canStart = false
|
|
private rootId: number | null = null
|
|
private pageFrames: HTMLIFrameElement[] = []
|
|
private frameOderNumber = 0
|
|
private features = {
|
|
'feature-flags': true,
|
|
'usability-test': true,
|
|
}
|
|
|
|
constructor(
|
|
projectKey: string,
|
|
sessionToken: string | undefined,
|
|
options: Partial<Options>,
|
|
private readonly signalError: (error: string, apis: string[]) => void,
|
|
public readonly insideIframe: boolean,
|
|
) {
|
|
this.contextId = Math.random().toString(36).slice(2)
|
|
this.projectKey = projectKey
|
|
|
|
if (
|
|
Object.keys(options).findIndex((k) => ['fixedCanvasScaling', 'disableCanvas'].includes(k)) !==
|
|
-1
|
|
) {
|
|
console.warn(
|
|
'Openreplay: canvas options are moving to separate key "canvas" in next update. Please update your configuration.',
|
|
)
|
|
options = {
|
|
...options,
|
|
canvas: {
|
|
__save_canvas_locally: options.__save_canvas_locally,
|
|
fixedCanvasScaling: options.fixedCanvasScaling,
|
|
disableCanvas: options.disableCanvas,
|
|
},
|
|
}
|
|
}
|
|
|
|
this.networkOptions = options.network
|
|
|
|
const defaultOptions: Options = {
|
|
revID: '',
|
|
node_id: '__openreplay_id',
|
|
session_token_key: '__openreplay_token',
|
|
session_pageno_key: '__openreplay_pageno',
|
|
session_reset_key: '__openreplay_reset',
|
|
session_tabid_key: '__openreplay_tabid',
|
|
local_uuid_key: '__openreplay_uuid',
|
|
ingestPoint: DEFAULT_INGEST_POINT,
|
|
resourceBaseHref: null,
|
|
__is_snippet: false,
|
|
__debug_report_edp: null,
|
|
__debug__: LogLevel.Silent,
|
|
__save_canvas_locally: false,
|
|
localStorage: null,
|
|
sessionStorage: null,
|
|
disableStringDict: false,
|
|
forceSingleTab: false,
|
|
assistSocketHost: '',
|
|
fixedCanvasScaling: false,
|
|
disableCanvas: false,
|
|
captureIFrames: true,
|
|
obscureTextEmails: true,
|
|
obscureTextNumbers: false,
|
|
crossdomain: {
|
|
parentDomain: '*',
|
|
},
|
|
canvas: {
|
|
disableCanvas: false,
|
|
fixedCanvasScaling: false,
|
|
__save_canvas_locally: false,
|
|
useAnimationFrame: false,
|
|
},
|
|
forceNgOff: false,
|
|
}
|
|
this.options = simpleMerge(defaultOptions, options)
|
|
|
|
if (
|
|
!this.insideIframe &&
|
|
!this.options.forceSingleTab &&
|
|
globalThis &&
|
|
'BroadcastChannel' in globalThis
|
|
) {
|
|
const host = location.hostname.split('.').slice(-2).join('_')
|
|
this.bc = new BroadcastChannel(`rick_${host}`)
|
|
}
|
|
|
|
this.revID = this.options.revID
|
|
this.localStorage = this.options.localStorage ?? window.localStorage
|
|
this.sessionStorage = this.options.sessionStorage ?? window.sessionStorage
|
|
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({ 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({
|
|
sessionStorage: this.sessionStorage,
|
|
errLog: this.debug.error,
|
|
onTag: (tag) => this.send(TagTrigger(tag) as Message),
|
|
})
|
|
this.session.attachUpdateCallback(({ userID, metadata }) => {
|
|
if (userID != null) {
|
|
// TODO: nullable userID
|
|
this.send(UserID(userID))
|
|
}
|
|
if (metadata != null) {
|
|
Object.entries(metadata).forEach(([key, value]) => this.send(Metadata(key, value)))
|
|
}
|
|
})
|
|
|
|
// @deprecated (use sessionHash on start instead)
|
|
if (sessionToken != null) {
|
|
this.session.applySessionHash(sessionToken)
|
|
}
|
|
|
|
const thisTab = this.session.getTabId()
|
|
|
|
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
|
|
* */
|
|
window.addEventListener('message', this.crossDomainIframeListener)
|
|
}
|
|
if (this.bc !== null) {
|
|
this.bc.postMessage({
|
|
line: proto.ask,
|
|
source: thisTab,
|
|
context: this.contextId,
|
|
})
|
|
this.startTimeout = setTimeout(() => {
|
|
this.allowAppStart()
|
|
}, 250)
|
|
this.bc.onmessage = (ev: MessageEvent<RickRoll>) => {
|
|
if (ev.data.context === this.contextId) {
|
|
return
|
|
}
|
|
if (ev.data.line === proto.resp) {
|
|
const sessionToken = ev.data.token
|
|
this.session.setSessionToken(sessionToken)
|
|
this.allowAppStart()
|
|
}
|
|
if (ev.data.line === proto.reg) {
|
|
const sessionToken = ev.data.token
|
|
this.session.regenerateTabId()
|
|
this.session.setSessionToken(sessionToken)
|
|
this.allowAppStart()
|
|
}
|
|
if (ev.data.line === proto.ask) {
|
|
const token = this.session.getSessionToken()
|
|
if (token && this.bc) {
|
|
this.bc.postMessage({
|
|
line: ev.data.source === thisTab ? proto.reg : proto.resp,
|
|
token,
|
|
source: thisTab,
|
|
context: this.contextId,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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.trackedFrames.includes(data.context)) {
|
|
try {
|
|
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)
|
|
}
|
|
} else {
|
|
this.debug.log('Couldnt get node id for iframe', event.source)
|
|
}
|
|
}
|
|
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 (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
|
|
public allowAppStart() {
|
|
this.canStart = true
|
|
if (this.startTimeout) {
|
|
clearTimeout(this.startTimeout)
|
|
this.startTimeout = null
|
|
}
|
|
}
|
|
|
|
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([workerBodyFn], { type: 'text/javascript' })),
|
|
)
|
|
this.worker.onerror = (e) => {
|
|
this._debug('webworker_error', e)
|
|
}
|
|
this.worker.onmessage = ({ data }: MessageEvent<FromWorkerData>) => {
|
|
this.handleWorkerMsg(data)
|
|
}
|
|
|
|
const alertWorker = () => {
|
|
if (this.worker) {
|
|
this.worker.postMessage(null)
|
|
}
|
|
}
|
|
// keep better tactics, discard others?
|
|
this.attachEventListener(window, 'beforeunload', alertWorker, false)
|
|
this.attachEventListener(document.body, 'mouseleave', alertWorker, false, false)
|
|
// TODO: stop session after inactivity timeout (make configurable)
|
|
this.attachEventListener(document, 'visibilitychange', alertWorker, false)
|
|
} catch (e) {
|
|
this._debug('worker_start', e)
|
|
}
|
|
}
|
|
|
|
private handleWorkerMsg(data: FromWorkerData) {
|
|
// handling 401 auth restart (new token assignment)
|
|
if (data === 'a_stop') {
|
|
this.stop(false)
|
|
} else if (data === 'a_start') {
|
|
this.waitStatus(ActivityState.NotActive).then(() => {
|
|
this.start({}, true)
|
|
.then((r) => {
|
|
this.debug.info('Worker restart, session 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') {
|
|
this.stop(false)
|
|
this.debug.error('worker_failed', data.reason)
|
|
this._debug('worker_failed', data.reason)
|
|
} else if (data.type === 'compress') {
|
|
const batch = data.batch
|
|
const batchSize = batch.byteLength
|
|
if (batchSize > this.compressionThreshold) {
|
|
gzip(data.batch, { mtime: 0 }, (err, result) => {
|
|
if (err) {
|
|
this.debug.error('Openreplay compression error:', err)
|
|
this.worker?.postMessage({ type: 'uncompressed', batch: batch })
|
|
} else {
|
|
this.worker?.postMessage({ type: 'compressed', batch: result })
|
|
}
|
|
})
|
|
} else {
|
|
this.worker?.postMessage({ type: 'uncompressed', batch: batch })
|
|
}
|
|
} else if (data.type === 'queue_empty') {
|
|
this.onSessionSent()
|
|
}
|
|
}
|
|
|
|
private _debug(context: string, e: any) {
|
|
if (this.options.__debug_report_edp !== null) {
|
|
void fetch(this.options.__debug_report_edp, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
context,
|
|
// @ts-ignore
|
|
error: `${e as unknown as string}`,
|
|
}),
|
|
})
|
|
}
|
|
this.debug.error('OpenReplay error: ', context, e)
|
|
}
|
|
|
|
send(message: Message, urgent = false): void {
|
|
if (this.activityState === ActivityState.NotActive) {
|
|
return
|
|
}
|
|
// ====================================================
|
|
if (this.activityState === ActivityState.ColdStart) {
|
|
this.bufferedMessages1.push(message)
|
|
if (!this.singleBuffer) {
|
|
this.bufferedMessages2.push(message)
|
|
}
|
|
this.conditionsManager?.processMessage(message)
|
|
} else {
|
|
this.messages.push(message)
|
|
}
|
|
// TODO: commit on start if there were `urgent` sends;
|
|
// Clarify where urgent can be used for;
|
|
// Clarify workflow for each type of message in case it was sent before start
|
|
// (like Fetch before start; maybe add an option "preCapture: boolean" or sth alike)
|
|
// Careful: `this.delay` is equal to zero before start so all Timestamp-s will have to be updated on start
|
|
if (this.activityState === ActivityState.Active && urgent) {
|
|
this.commit()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normal workflow: add timestamp and tab data to batch, then commit it
|
|
* every ~30ms
|
|
* */
|
|
private _nCommit(): void {
|
|
if (this.socketMode) {
|
|
this.messages.unshift(TabData(this.session.getTabId()))
|
|
this.messages.unshift(Timestamp(this.timestamp()))
|
|
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
|
this.messages.length = 0
|
|
return
|
|
}
|
|
|
|
if (this.insideIframe) {
|
|
window.parent.postMessage(
|
|
{
|
|
line: proto.iframeBatch,
|
|
messages: this.messages,
|
|
},
|
|
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()))
|
|
this.worker?.postMessage(this.messages)
|
|
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
|
this.messages.length = 0
|
|
})
|
|
} catch (e) {
|
|
this._debug('worker_commit', e)
|
|
this.stop(true)
|
|
setTimeout(() => {
|
|
void this.start()
|
|
}, 500)
|
|
}
|
|
}
|
|
|
|
coldStartCommitN = 0
|
|
|
|
/**
|
|
* Cold start: add timestamp and tab data to both batches
|
|
* every 2nd tick, ~60ms
|
|
* this will make batches a bit larger and replay will work with bigger jumps every frame
|
|
* but in turn we don't overload batch writer on session start with 1000 batches
|
|
* */
|
|
private _cStartCommit(): void {
|
|
this.coldStartCommitN += 1
|
|
if (this.coldStartCommitN === 2) {
|
|
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
|
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
|
this.bufferedMessages2.push(Timestamp(this.timestamp()))
|
|
this.bufferedMessages2.push(TabData(this.session.getTabId()))
|
|
this.coldStartCommitN = 0
|
|
}
|
|
}
|
|
|
|
private commit(): void {
|
|
if (this.activityState === ActivityState.ColdStart) {
|
|
this._cStartCommit()
|
|
} else {
|
|
this._nCommit()
|
|
}
|
|
}
|
|
|
|
private postToWorker(messages: Array<Message>) {
|
|
this.worker?.postMessage(messages)
|
|
this.commitCallbacks.forEach((cb) => cb(messages))
|
|
messages.length = 0
|
|
}
|
|
|
|
private delay = 0
|
|
|
|
timestamp(): number {
|
|
return now() + this.delay
|
|
}
|
|
|
|
safe<T extends (this: any, ...args: any[]) => void>(fn: T): T {
|
|
const app = this
|
|
return function (this: any, ...args: any[]) {
|
|
try {
|
|
fn.apply(this, args)
|
|
} catch (e) {
|
|
app._debug('safe_fn_call', e)
|
|
// time: this.timestamp(),
|
|
// name: e.name,
|
|
// message: e.message,
|
|
// stack: e.stack
|
|
}
|
|
} as T // TODO: correct typing
|
|
}
|
|
|
|
attachCommitCallback(cb: CommitCallback): void {
|
|
this.commitCallbacks.push(cb)
|
|
}
|
|
|
|
attachStartCallback = (cb: StartCallback, useSafe = false): void => {
|
|
if (useSafe) {
|
|
cb = this.safe(cb)
|
|
}
|
|
this.startCallbacks.push(cb)
|
|
}
|
|
|
|
attachStopCallback = (cb: () => any, useSafe = false): void => {
|
|
if (useSafe) {
|
|
cb = this.safe(cb)
|
|
}
|
|
this.stopCallbacks.push(cb)
|
|
}
|
|
|
|
attachEventListener = (
|
|
target: EventTarget,
|
|
type: string,
|
|
listener: EventListener,
|
|
useSafe = true,
|
|
useCapture = true,
|
|
): void => {
|
|
if (useSafe) {
|
|
listener = this.safe(listener)
|
|
}
|
|
|
|
const createListener = () =>
|
|
target
|
|
? createEventListener(target, type, listener, useCapture, this.options.forceNgOff)
|
|
: null
|
|
const deleteListener = () =>
|
|
target
|
|
? deleteEventListener(target, type, listener, useCapture, this.options.forceNgOff)
|
|
: null
|
|
|
|
this.attachStartCallback(createListener, useSafe)
|
|
this.attachStopCallback(deleteListener, useSafe)
|
|
}
|
|
|
|
// TODO: full correct semantic
|
|
checkRequiredVersion(version: string): boolean {
|
|
const reqVer = version.split(/[.-]/)
|
|
const ver = this.version.split(/[.-]/)
|
|
for (let i = 0; i < 3; i++) {
|
|
if (isNaN(Number(ver[i])) || isNaN(Number(reqVer[i]))) {
|
|
return false
|
|
}
|
|
if (Number(ver[i]) > Number(reqVer[i])) {
|
|
return true
|
|
}
|
|
if (Number(ver[i]) < Number(reqVer[i])) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private getTrackerInfo() {
|
|
return {
|
|
userUUID: this.localStorage.getItem(this.options.local_uuid_key),
|
|
projectKey: this.projectKey,
|
|
revID: this.revID,
|
|
trackerVersion: this.version,
|
|
isSnippet: this.options.__is_snippet,
|
|
}
|
|
}
|
|
|
|
getSessionInfo() {
|
|
return {
|
|
...this.session.getInfo(),
|
|
...this.getTrackerInfo(),
|
|
}
|
|
}
|
|
|
|
getSessionToken(): string | undefined {
|
|
return this.session.getSessionToken()
|
|
}
|
|
|
|
getSessionID(): string | undefined {
|
|
return this.session.getInfo().sessionID || undefined
|
|
}
|
|
|
|
getSessionURL(options?: { withCurrentTime?: boolean }): string | undefined {
|
|
const { projectID, sessionID, timestamp } = this.session.getInfo()
|
|
if (!projectID || !sessionID) {
|
|
this.debug.error('OpenReplay error: Unable to build session URL')
|
|
return undefined
|
|
}
|
|
const ingest = this.options.ingestPoint
|
|
const isSaas = /api\.openreplay\.com/.test(ingest)
|
|
|
|
const projectPath = isSaas ? 'https://app.openreplay.com/ingest' : ingest
|
|
|
|
const url = projectPath.replace(/ingest$/, `${projectID}/session/${sessionID}`)
|
|
|
|
if (options?.withCurrentTime) {
|
|
const jumpTo = now() - timestamp
|
|
return `${url}?jumpto=${jumpTo}`
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
getHost(): string {
|
|
return new URL(this.options.ingestPoint).host
|
|
}
|
|
|
|
getProjectKey(): string {
|
|
return this.projectKey
|
|
}
|
|
|
|
getBaseHref(): string {
|
|
if (typeof this.options.resourceBaseHref === 'string') {
|
|
return this.options.resourceBaseHref
|
|
} else if (typeof this.options.resourceBaseHref === 'object') {
|
|
//TODO: switch between types
|
|
}
|
|
if (document.baseURI) {
|
|
return document.baseURI
|
|
}
|
|
// IE only
|
|
return (
|
|
document.head?.getElementsByTagName('base')[0]?.getAttribute('href') ||
|
|
location.origin + location.pathname
|
|
)
|
|
}
|
|
|
|
resolveResourceURL(resourceURL: string): string {
|
|
const base = new URL(this.getBaseHref())
|
|
base.pathname += '/' + new URL(resourceURL).pathname
|
|
base.pathname.replace(/\/+/g, '/')
|
|
return base.toString()
|
|
}
|
|
|
|
isServiceURL(url: string): boolean {
|
|
return url.startsWith(this.options.ingestPoint)
|
|
}
|
|
|
|
active(): boolean {
|
|
return this.activityState === ActivityState.Active
|
|
}
|
|
|
|
resetNextPageSession(flag: boolean) {
|
|
if (flag) {
|
|
this.sessionStorage.setItem(this.options.session_reset_key, 't')
|
|
} else {
|
|
this.sessionStorage.removeItem(this.options.session_reset_key)
|
|
}
|
|
}
|
|
|
|
coldInterval: ReturnType<typeof setInterval> | null = null
|
|
orderNumber = 0
|
|
coldStartTs = 0
|
|
singleBuffer = false
|
|
|
|
private checkSessionToken(forceNew?: boolean) {
|
|
const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null
|
|
const needNewSessionID = forceNew || lsReset
|
|
const sessionToken = this.session.getSessionToken()
|
|
|
|
return needNewSessionID || !sessionToken
|
|
}
|
|
|
|
/**
|
|
* start buffering messages without starting the actual session, which gives
|
|
* user 30 seconds to "activate" and record session by calling `start()` on conditional trigger,
|
|
* and we will then send buffered batch, so it won't get lost
|
|
* */
|
|
public async coldStart(startOpts: StartOptions = {}, conditional?: boolean) {
|
|
this.singleBuffer = false
|
|
const second = 1000
|
|
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
|
if (conditional) {
|
|
await this.setupConditionalStart(startOpts)
|
|
}
|
|
const cycle = () => {
|
|
this.orderNumber += 1
|
|
adjustTimeOrigin()
|
|
this.coldStartTs = now()
|
|
if (this.orderNumber % 2 === 0) {
|
|
this.bufferedMessages1.length = 0
|
|
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
|
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
|
} else {
|
|
this.bufferedMessages2.length = 0
|
|
this.bufferedMessages2.push(Timestamp(this.timestamp()))
|
|
this.bufferedMessages2.push(TabData(this.session.getTabId()))
|
|
}
|
|
this.stop(false)
|
|
this.activityState = ActivityState.ColdStart
|
|
if (startOpts.sessionHash) {
|
|
this.session.applySessionHash(startOpts.sessionHash)
|
|
}
|
|
if (startOpts.forceNew) {
|
|
this.session.reset()
|
|
}
|
|
this.session.assign({
|
|
userID: startOpts.userID,
|
|
metadata: startOpts.metadata,
|
|
})
|
|
if (!isNewSession) {
|
|
this.debug.log('continuing session on new tab', this.session.getTabId())
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
this.send(TabChange(this.session.getTabId()))
|
|
}
|
|
this.observer.observe()
|
|
this.ticker.start()
|
|
}
|
|
this.coldInterval = setInterval(() => {
|
|
cycle()
|
|
}, 30 * second)
|
|
cycle()
|
|
}
|
|
|
|
private async setupConditionalStart(startOpts: StartOptions) {
|
|
this.conditionsManager = new ConditionsManager(this, startOpts)
|
|
const r = await fetch(this.options.ingestPoint + '/v1/web/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
...this.getTrackerInfo(),
|
|
timestamp: now(),
|
|
doNotRecord: true,
|
|
bufferDiff: 0,
|
|
userID: this.session.getInfo().userID,
|
|
token: undefined,
|
|
deviceMemory,
|
|
jsHeapSizeLimit,
|
|
timezone: getTimezone(),
|
|
width: window.screen.width,
|
|
height: window.screen.height,
|
|
}),
|
|
})
|
|
const {
|
|
// this token is needed to fetch conditions and flags,
|
|
// but it can't be used to record a session
|
|
token,
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
projectID,
|
|
features,
|
|
} = await r.json()
|
|
this.features = features ? features : this.features
|
|
this.session.assign({ projectID })
|
|
this.session.setUserInfo({
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
})
|
|
const onStartInfo = { sessionToken: token, userUUID: '', sessionID: '' }
|
|
this.startCallbacks.forEach((cb) => cb(onStartInfo))
|
|
await this.conditionsManager?.fetchConditions(projectID as string, token as string)
|
|
if (this.features['feature-flags']) {
|
|
await this.featureFlags.reloadFlags(token as string)
|
|
this.conditionsManager?.processFlags(this.featureFlags.flags)
|
|
}
|
|
await this.tagWatcher.fetchTags(this.options.ingestPoint, token as string)
|
|
}
|
|
|
|
onSessionSent = () => {
|
|
return
|
|
}
|
|
|
|
/**
|
|
* Starts offline session recording
|
|
* @param {Object} startOpts - options for session start, same as .start()
|
|
* @param {Function} onSessionSent - callback that will be called once session is fully sent
|
|
* */
|
|
public offlineRecording(startOpts: StartOptions = {}, onSessionSent: () => void) {
|
|
this.onSessionSent = onSessionSent
|
|
this.singleBuffer = true
|
|
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
|
adjustTimeOrigin()
|
|
this.coldStartTs = now()
|
|
const saverBuffer = this.localStorage.getItem(bufferStorageKey)
|
|
if (saverBuffer) {
|
|
const data = JSON.parse(saverBuffer)
|
|
this.bufferedMessages1 = Array.isArray(data) ? data : this.bufferedMessages1
|
|
this.localStorage.removeItem(bufferStorageKey)
|
|
}
|
|
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
|
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
|
this.activityState = ActivityState.ColdStart
|
|
if (startOpts.sessionHash) {
|
|
this.session.applySessionHash(startOpts.sessionHash)
|
|
}
|
|
if (startOpts.forceNew) {
|
|
this.session.reset()
|
|
}
|
|
this.session.assign({
|
|
userID: startOpts.userID,
|
|
metadata: startOpts.metadata,
|
|
})
|
|
const onStartInfo = { sessionToken: '', userUUID: '', sessionID: '' }
|
|
this.startCallbacks.forEach((cb) => cb(onStartInfo))
|
|
if (!isNewSession) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
this.send(TabChange(this.session.getTabId()))
|
|
}
|
|
this.observer.observe()
|
|
this.ticker.start()
|
|
|
|
return {
|
|
saveBuffer: this.saveBuffer,
|
|
getBuffer: this.getBuffer,
|
|
setBuffer: this.setBuffer,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves the captured messages in localStorage (or whatever is used in its place)
|
|
*
|
|
* Then, when this.offlineRecording is called, it will preload this messages and clear the storage item
|
|
*
|
|
* Keeping the size of local storage reasonable is up to the end users of this library
|
|
* */
|
|
public saveBuffer() {
|
|
this.localStorage.setItem(bufferStorageKey, JSON.stringify(this.bufferedMessages1))
|
|
}
|
|
|
|
/**
|
|
* @returns buffer with stored messages for offline recording
|
|
* */
|
|
public getBuffer() {
|
|
return this.bufferedMessages1
|
|
}
|
|
|
|
/**
|
|
* Used to set a buffer with messages array
|
|
* */
|
|
public setBuffer(buffer: Message[]) {
|
|
this.bufferedMessages1 = buffer
|
|
}
|
|
|
|
/**
|
|
* Uploads the stored session buffer to backend
|
|
* @returns promise that resolves once messages are loaded, it has to be awaited
|
|
* so the session can be uploaded properly
|
|
* @resolve - if messages were loaded in service worker successfully
|
|
* @reject {string} - error message
|
|
* */
|
|
public async uploadOfflineRecording() {
|
|
this.stop(false)
|
|
const timestamp = now()
|
|
this.worker?.postMessage({
|
|
type: 'start',
|
|
pageNo: this.session.incPageNo(),
|
|
ingestPoint: this.options.ingestPoint,
|
|
timestamp: this.coldStartTs,
|
|
url: document.URL,
|
|
connAttemptCount: this.options.connAttemptCount,
|
|
connAttemptGap: this.options.connAttemptGap,
|
|
tabId: this.session.getTabId(),
|
|
})
|
|
const r = await fetch(this.options.ingestPoint + '/v1/web/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
...this.getTrackerInfo(),
|
|
timestamp: timestamp,
|
|
doNotRecord: false,
|
|
bufferDiff: timestamp - this.coldStartTs,
|
|
userID: this.session.getInfo().userID,
|
|
token: undefined,
|
|
deviceMemory,
|
|
jsHeapSizeLimit,
|
|
timezone: getTimezone(),
|
|
}),
|
|
})
|
|
const {
|
|
token,
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
beaconSizeLimit,
|
|
projectID,
|
|
} = await r.json()
|
|
this.worker?.postMessage({
|
|
type: 'auth',
|
|
token,
|
|
beaconSizeLimit,
|
|
})
|
|
this.session.assign({ projectID })
|
|
this.session.setUserInfo({
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
})
|
|
while (this.bufferedMessages1.length > 0) {
|
|
await this.flushBuffer(this.bufferedMessages1)
|
|
}
|
|
this.postToWorker([['q_end']] as unknown as Message[])
|
|
this.clearBuffers()
|
|
}
|
|
|
|
private async _start(
|
|
startOpts: StartOptions = {},
|
|
resetByWorker = false,
|
|
conditionName?: string,
|
|
): Promise<StartPromiseReturn> {
|
|
const isColdStart = this.activityState === ActivityState.ColdStart
|
|
if (isColdStart && this.coldInterval) {
|
|
clearInterval(this.coldInterval)
|
|
}
|
|
if (!this.worker && !this.insideIframe) {
|
|
const reason = 'No worker found: perhaps, CSP is not set.'
|
|
this.signalError(reason, [])
|
|
return Promise.resolve(UnsuccessfulStart(reason))
|
|
}
|
|
if (
|
|
this.activityState === ActivityState.Active ||
|
|
this.activityState === ActivityState.Starting
|
|
) {
|
|
const reason =
|
|
'OpenReplay: trying to call `start()` on the instance that has been started already.'
|
|
return Promise.resolve(UnsuccessfulStart(reason))
|
|
}
|
|
this.activityState = ActivityState.Starting
|
|
if (!isColdStart) {
|
|
adjustTimeOrigin()
|
|
}
|
|
|
|
if (startOpts.sessionHash) {
|
|
this.session.applySessionHash(startOpts.sessionHash)
|
|
}
|
|
if (startOpts.forceNew) {
|
|
// Reset session metadata only if requested directly
|
|
this.session.reset()
|
|
}
|
|
this.session.assign({
|
|
// MBTODO: maybe it would make sense to `forceNew` if the `userID` was changed
|
|
userID: startOpts.userID,
|
|
metadata: startOpts.metadata,
|
|
})
|
|
|
|
const timestamp = now()
|
|
this.worker?.postMessage({
|
|
type: 'start',
|
|
pageNo: this.session.incPageNo(),
|
|
ingestPoint: this.options.ingestPoint,
|
|
timestamp: isColdStart ? this.coldStartTs : timestamp,
|
|
url: document.URL,
|
|
connAttemptCount: this.options.connAttemptCount,
|
|
connAttemptGap: this.options.connAttemptGap,
|
|
tabId: this.session.getTabId(),
|
|
})
|
|
|
|
const sessionToken = this.session.getSessionToken()
|
|
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
|
this.sessionStorage.removeItem(this.options.session_reset_key)
|
|
|
|
this.debug.log(
|
|
'OpenReplay: starting session; need new session id?',
|
|
isNewSession,
|
|
'session token: ',
|
|
sessionToken,
|
|
)
|
|
try {
|
|
const r = await window.fetch(this.options.ingestPoint + '/v1/web/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
...this.getTrackerInfo(),
|
|
timestamp,
|
|
doNotRecord: false,
|
|
bufferDiff: timestamp - this.coldStartTs,
|
|
userID: this.session.getInfo().userID,
|
|
token: isNewSession ? undefined : sessionToken,
|
|
deviceMemory,
|
|
jsHeapSizeLimit,
|
|
timezone: getTimezone(),
|
|
condition: conditionName,
|
|
assistOnly: startOpts.assistOnly ?? this.socketMode,
|
|
width: window.screen.width,
|
|
height: window.screen.height,
|
|
}),
|
|
})
|
|
if (r.status !== 200) {
|
|
const error = await r.text()
|
|
const reason = error === CANCELED ? CANCELED : `Server error: ${r.status}. ${error}`
|
|
return UnsuccessfulStart(reason)
|
|
}
|
|
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)
|
|
}
|
|
const {
|
|
token,
|
|
userUUID,
|
|
projectID,
|
|
beaconSizeLimit,
|
|
compressionThreshold, // how big the batch should be before we decide to compress it
|
|
delay, // derived from token
|
|
sessionID, // derived from token
|
|
startTimestamp, // real startTS (server time), derived from sessionID
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
canvasEnabled,
|
|
canvasQuality,
|
|
canvasFPS,
|
|
assistOnly: socketOnly,
|
|
features,
|
|
} = await r.json()
|
|
this.features = features ? features : this.features
|
|
if (
|
|
typeof token !== 'string' ||
|
|
typeof userUUID !== 'string' ||
|
|
(typeof startTimestamp !== 'number' && typeof startTimestamp !== 'undefined') ||
|
|
typeof sessionID !== 'string' ||
|
|
typeof delay !== 'number' ||
|
|
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')
|
|
) {
|
|
const reason = `Incorrect server response: ${JSON.stringify(r)}`
|
|
this.signalError(reason, [])
|
|
return UnsuccessfulStart(reason)
|
|
}
|
|
|
|
this.delay = delay
|
|
this.session.setSessionToken(token)
|
|
this.session.setUserInfo({
|
|
userBrowser,
|
|
userCity,
|
|
userCountry,
|
|
userDevice,
|
|
userOS,
|
|
userState,
|
|
})
|
|
this.session.assign({
|
|
sessionID,
|
|
timestamp: startTimestamp || timestamp,
|
|
projectID,
|
|
})
|
|
|
|
if (socketOnly) {
|
|
this.socketMode = true
|
|
this.worker?.postMessage('stop')
|
|
} else {
|
|
this.worker?.postMessage({
|
|
type: 'auth',
|
|
token,
|
|
beaconSizeLimit,
|
|
})
|
|
}
|
|
|
|
if (!isNewSession && token === sessionToken) {
|
|
this.debug.log('continuing session on new tab', this.session.getTabId())
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
this.send(TabChange(this.session.getTabId()))
|
|
}
|
|
// (Re)send Metadata for the case of a new session
|
|
Object.entries(this.session.getInfo().metadata).forEach(([key, value]) =>
|
|
this.send(Metadata(key, value)),
|
|
)
|
|
this.localStorage.setItem(this.options.local_uuid_key, userUUID)
|
|
|
|
this.compressionThreshold = compressionThreshold
|
|
const onStartInfo = { sessionToken: token, userUUID, sessionID }
|
|
// 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 =
|
|
this.canvasRecorder ??
|
|
new CanvasRecorder(this, {
|
|
fps: canvasFPS,
|
|
quality: canvasQuality,
|
|
isDebug: this.options.canvas.__save_canvas_locally,
|
|
fixedScaling: this.options.canvas.fixedCanvasScaling,
|
|
useAnimationFrame: this.options.canvas.useAnimationFrame,
|
|
})
|
|
}
|
|
|
|
/** --------------- COLD START BUFFER ------------------*/
|
|
if (isColdStart) {
|
|
const biggestBuffer =
|
|
this.bufferedMessages1.length > this.bufferedMessages2.length
|
|
? this.bufferedMessages1
|
|
: this.bufferedMessages2
|
|
while (biggestBuffer.length > 0) {
|
|
await this.flushBuffer(biggestBuffer)
|
|
}
|
|
this.clearBuffers()
|
|
this.commit()
|
|
/** --------------- COLD START BUFFER ------------------*/
|
|
} else {
|
|
if (this.insideIframe && this.rootId) {
|
|
this.observer.crossdomainObserve(this.rootId, this.frameOderNumber)
|
|
} else {
|
|
this.observer.observe()
|
|
}
|
|
this.ticker.start()
|
|
}
|
|
this.canvasRecorder?.startTracking()
|
|
|
|
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) {
|
|
uxtId = parseInt(savedUxtTag, 10)
|
|
}
|
|
if (location?.search) {
|
|
const query = new URLSearchParams(location.search)
|
|
if (query.has('oruxt')) {
|
|
const qId = query.get('oruxt')
|
|
uxtId = qId ? parseInt(qId, 10) : undefined
|
|
}
|
|
}
|
|
|
|
if (uxtId) {
|
|
if (!this.uxtManager.isActive) {
|
|
// eslint-disable-next-line
|
|
this.uxtManager.getTest(uxtId, token, Boolean(savedUxtTag)).then((id) => {
|
|
if (id) {
|
|
this.onUxtCb.forEach((cb: (id: number) => void) => cb(id))
|
|
}
|
|
})
|
|
} else {
|
|
// @ts-ignore
|
|
this.onUxtCb.forEach((cb: (id: number) => void) => cb(uxtId))
|
|
}
|
|
}
|
|
}
|
|
|
|
return SuccessfulStart(onStartInfo)
|
|
} 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)
|
|
}
|
|
|
|
this._debug('session_start', reason)
|
|
const errorMessage = reason instanceof Error ? reason.message : reason.toString()
|
|
this.signalError(errorMessage, [])
|
|
return UnsuccessfulStart(errorMessage)
|
|
}
|
|
}
|
|
|
|
restartCanvasTracking = () => {
|
|
this.canvasRecorder?.restartTracking()
|
|
}
|
|
|
|
flushBuffer = async (buffer: Message[]) => {
|
|
return new Promise((res) => {
|
|
let ended = false
|
|
const messagesBatch: Message[] = [buffer.shift() as unknown as Message]
|
|
while (!ended) {
|
|
const nextMsg = buffer[0]
|
|
if (!nextMsg || nextMsg[0] === MType.Timestamp) {
|
|
ended = true
|
|
} else {
|
|
messagesBatch.push(buffer.shift() as unknown as Message)
|
|
}
|
|
}
|
|
this.postToWorker(messagesBatch)
|
|
res(null)
|
|
})
|
|
}
|
|
|
|
onUxtCb = []
|
|
|
|
addOnUxtCb(cb: (id: number) => void) {
|
|
// @ts-ignore
|
|
this.onUxtCb.push(cb)
|
|
}
|
|
|
|
getUxtId(): number | null {
|
|
return this.uxtManager?.getTestId()
|
|
}
|
|
|
|
async waitStart() {
|
|
return new Promise((resolve) => {
|
|
const int = setInterval(() => {
|
|
if (this.canStart) {
|
|
clearInterval(int)
|
|
resolve(true)
|
|
}
|
|
}, 100)
|
|
})
|
|
}
|
|
|
|
async waitStarted() {
|
|
return this.waitStatus(ActivityState.Active)
|
|
}
|
|
|
|
async waitStatus(status: ActivityState) {
|
|
return new Promise((resolve) => {
|
|
const check = () => {
|
|
if (this.activityState === status) {
|
|
resolve(true)
|
|
} else {
|
|
setTimeout(check, 25)
|
|
}
|
|
}
|
|
check()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* basically we ask other tabs during constructor
|
|
* and here we just apply 10ms delay just in case
|
|
* */
|
|
async start(...args: Parameters<App['_start']>): Promise<StartPromiseReturn> {
|
|
if (
|
|
this.activityState === ActivityState.Active ||
|
|
this.activityState === ActivityState.Starting
|
|
) {
|
|
const reason =
|
|
'OpenReplay: trying to call `start()` on the instance that has been started already.'
|
|
return Promise.resolve(UnsuccessfulStart(reason))
|
|
}
|
|
|
|
if (this.insideIframe) {
|
|
this.signalIframeTracker()
|
|
}
|
|
|
|
if (!document.hidden) {
|
|
await this.waitStart()
|
|
return this._start(...args)
|
|
} else {
|
|
return new Promise((resolve) => {
|
|
const onVisibilityChange = async () => {
|
|
if (!document.hidden) {
|
|
await this.waitStart()
|
|
// eslint-disable-next-line
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
resolve(this._start(...args))
|
|
}
|
|
}
|
|
// eslint-disable-next-line
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
})
|
|
}
|
|
}
|
|
|
|
forceFlushBatch() {
|
|
this.worker?.postMessage('forceFlushBatch')
|
|
}
|
|
|
|
getTabId() {
|
|
return this.session.getTabId()
|
|
}
|
|
|
|
clearBuffers() {
|
|
this.bufferedMessages1.length = 0
|
|
this.bufferedMessages2.length = 0
|
|
}
|
|
|
|
/**
|
|
* Creates a named hook that expects event name, data string and msg direction (up/down),
|
|
* it will skip any message bigger than 5 mb or event name bigger than 255 symbols
|
|
* @returns {(msgType: string, data: string, dir: "up" | "down") => void}
|
|
* */
|
|
trackWs(channelName: string): (msgType: string, data: string, dir: 'up' | 'down') => void {
|
|
const channel = channelName
|
|
return (msgType: string, data: string, dir: 'up' | 'down' = 'down') => {
|
|
if (
|
|
typeof msgType !== 'string' ||
|
|
typeof data !== 'string' ||
|
|
data.length > 5 * 1024 * 1024 ||
|
|
msgType.length > 255
|
|
) {
|
|
return
|
|
}
|
|
this.send(WSChannel('websocket', channel, data, this.timestamp(), dir, msgType))
|
|
}
|
|
}
|
|
|
|
stop(stopWorker = true): void {
|
|
if (this.activityState !== ActivityState.NotActive) {
|
|
console.trace('stopped')
|
|
try {
|
|
if (!this.insideIframe && this.options.crossdomain?.enabled) {
|
|
this.killChildrenFrames()
|
|
}
|
|
this.attributeSender.clear()
|
|
this.sanitizer.clear()
|
|
this.observer.disconnect()
|
|
this.nodes.clear()
|
|
this.ticker.stop()
|
|
this.stopCallbacks.forEach((cb) => cb())
|
|
this.tagWatcher.clear()
|
|
if (this.worker && stopWorker) {
|
|
this.worker.postMessage('stop')
|
|
}
|
|
this.canvasRecorder?.clear()
|
|
this.messages.length = 0
|
|
this.trackedFrames = []
|
|
this.parentActive = false
|
|
this.canStart = false
|
|
} finally {
|
|
this.activityState = ActivityState.NotActive
|
|
this.debug.log('OpenReplay tracking stopped.')
|
|
}
|
|
}
|
|
}
|
|
}
|