Compare commits
12 commits
main
...
tracker-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ed3add79 | ||
|
|
96f7a25641 | ||
|
|
71e0c6915e | ||
|
|
8ecc67c745 | ||
|
|
999e5cba3a | ||
|
|
9c6ef3182d | ||
|
|
1f77d432ca | ||
|
|
2dcedc9137 | ||
|
|
6765dadf09 | ||
|
|
ef6f18d0f3 | ||
|
|
fb550a26dc | ||
|
|
a8c68e413c |
15 changed files with 595 additions and 63 deletions
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +1,31 @@
|
|||
## 15.1.1
|
||||
|
||||
- fix for attributeSender dictionary keys
|
||||
|
||||
## 15.1.0
|
||||
|
||||
- move domparser for sprites under observer code for better SSR support
|
||||
- introduce singleton approach for tracker
|
||||
```js
|
||||
import { tracker } from '@openreplay/tracker'
|
||||
|
||||
// configure it once
|
||||
tracker.configure({ ...options })
|
||||
|
||||
// use it anywhere
|
||||
// .../main/app.tsx
|
||||
import { tracker } from '@openreplay/tracker'
|
||||
tracker.start()
|
||||
```
|
||||
|
||||
## 15.0.7
|
||||
|
||||
- fix for svg sprite handling
|
||||
|
||||
## 15.0.6
|
||||
|
||||
- fix for batch sending to prevent proxy wrappers
|
||||
|
||||
## 15.0.5
|
||||
|
||||
- update medv/finder to 4.0.2 for better support of css-in-js libs
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "15.0.5-beta.1",
|
||||
"version": "15.1.2",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
@ -15,23 +15,27 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/cjs/entry.js",
|
||||
"import": "./dist/lib/entry.js",
|
||||
"types": "./dist/lib/main/entry.d.ts"
|
||||
},
|
||||
"./class": {
|
||||
"require": "./dist/cjs/index.js",
|
||||
"import": "./dist/lib/index.js",
|
||||
"types": "./dist/lib/main/index.d.ts"
|
||||
},
|
||||
"./cjs": {
|
||||
"require": "./dist/cjs/index.js",
|
||||
"types": "./dist/cjs/main/index.d.ts"
|
||||
"require": "./dist/cjs/entry.js",
|
||||
"types": "./dist/cjs/main/entry.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/lib/**/*",
|
||||
"dist/cjs/**/*",
|
||||
"dist/types/**/*"
|
||||
"dist/cjs/**/*"
|
||||
],
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/lib/index.js",
|
||||
"types": "./dist/lib/main/index.d.ts",
|
||||
"main": "./dist/cjs/entry.js",
|
||||
"module": "./dist/lib/entry.js",
|
||||
"types": "./dist/lib/main/entry.d.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.js --fix --quiet",
|
||||
"clean": "rm -Rf build && rm -Rf dist",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import typescript from '@rollup/plugin-typescript'
|
|||
import terser from '@rollup/plugin-terser'
|
||||
import replace from '@rollup/plugin-replace'
|
||||
import { rollup } from 'rollup'
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import { createRequire } from 'module'
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageConfig = require('./package.json')
|
||||
|
|
@ -21,33 +21,32 @@ export default async () => {
|
|||
},
|
||||
}),
|
||||
]
|
||||
return [
|
||||
{
|
||||
input: 'build/main/index.js',
|
||||
output: {
|
||||
dir: 'dist/lib',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
...commonPlugins,
|
||||
],
|
||||
|
||||
const entryPoints = ['build/main/index.js', 'build/main/entry.js']
|
||||
|
||||
const esmBuilds = entryPoints.map((input) => ({
|
||||
input,
|
||||
output: {
|
||||
dir: 'dist/lib',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
{
|
||||
input: 'build/main/index.js',
|
||||
output: {
|
||||
dir: 'dist/cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
...commonPlugins,
|
||||
commonjs(),
|
||||
],
|
||||
plugins: [...commonPlugins],
|
||||
}))
|
||||
|
||||
const cjsBuilds = entryPoints.map((input) => ({
|
||||
input,
|
||||
output: {
|
||||
dir: 'dist/cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
]
|
||||
plugins: [...commonPlugins, commonjs()],
|
||||
}))
|
||||
|
||||
return [...esmBuilds, ...cjsBuilds]
|
||||
}
|
||||
|
||||
async function buildWebWorker() {
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ export default class App {
|
|||
'feature-flags': true,
|
||||
'usability-test': true,
|
||||
}
|
||||
private emptyBatchCounter = 0
|
||||
|
||||
constructor(
|
||||
projectKey: string,
|
||||
|
|
@ -324,6 +325,7 @@ export default class App {
|
|||
fixedCanvasScaling: false,
|
||||
disableCanvas: false,
|
||||
captureIFrames: true,
|
||||
disableSprites: false,
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
crossdomain: {
|
||||
|
|
@ -847,8 +849,7 @@ export default class App {
|
|||
* */
|
||||
private _nCommit(): void {
|
||||
if (this.socketMode) {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()), TabData(this.session.getTabId()))
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
return
|
||||
|
|
@ -871,10 +872,19 @@ export default class App {
|
|||
return
|
||||
}
|
||||
|
||||
if (!this.messages.length) {
|
||||
if (this.emptyBatchCounter < 1000) {
|
||||
this.emptyBatchCounter++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.emptyBatchCounter = 0
|
||||
console.log('messages', this.messages.join(', '))
|
||||
|
||||
try {
|
||||
requestIdleCb(() => {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()))
|
||||
this.messages.unshift(Timestamp(this.timestamp()), TabData(this.session.getTabId()))
|
||||
this.worker?.postMessage(this.messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(this.messages))
|
||||
this.messages.length = 0
|
||||
|
|
@ -899,10 +909,9 @@ export default class App {
|
|||
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()))
|
||||
const payload = [Timestamp(this.timestamp()), TabData(this.session.getTabId())]
|
||||
this.bufferedMessages1.push(...payload)
|
||||
this.bufferedMessages2.push(...payload)
|
||||
this.coldStartCommitN = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1636,7 +1645,7 @@ export default class App {
|
|||
}
|
||||
|
||||
flushBuffer = async (buffer: Message[]) => {
|
||||
return new Promise((res) => {
|
||||
return new Promise((res, reject) => {
|
||||
if (buffer.length === 0) {
|
||||
res(null)
|
||||
return
|
||||
|
|
@ -1648,9 +1657,19 @@ export default class App {
|
|||
endIndex++
|
||||
}
|
||||
|
||||
const messagesBatch = buffer.splice(0, endIndex)
|
||||
this.postToWorker(messagesBatch)
|
||||
res(null)
|
||||
requestIdleCb(() => {
|
||||
try {
|
||||
const messagesBatch = buffer.splice(0, endIndex)
|
||||
|
||||
// Cast out the proxy object to a regular array.
|
||||
this.postToWorker(messagesBatch.map((x) => [...x]))
|
||||
|
||||
res(null)
|
||||
} catch (e) {
|
||||
this._debug('flushBuffer', e)
|
||||
reject(new Error('flush buffer fail'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ import {
|
|||
} from '../guards.js'
|
||||
|
||||
const iconCache = {}
|
||||
const domParser = new DOMParser()
|
||||
const svgUrlCache = {}
|
||||
|
||||
async function parseUseEl(useElement: SVGUseElement, mode: 'inline' | 'dataurl' | 'svgtext') {
|
||||
async function parseUseEl(
|
||||
useElement: SVGUseElement,
|
||||
mode: 'inline' | 'dataurl' | 'svgtext',
|
||||
domParser: DOMParser,
|
||||
) {
|
||||
try {
|
||||
const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href')
|
||||
if (!href) {
|
||||
|
|
@ -43,15 +47,42 @@ async function parseUseEl(useElement: SVGUseElement, mode: 'inline' | 'dataurl'
|
|||
return iconCache[symbolId]
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const svgText = await response.text()
|
||||
let svgDoc: Document
|
||||
if (svgUrlCache[url]) {
|
||||
if (svgUrlCache[url] === 1) {
|
||||
await new Promise((resolve) => {
|
||||
let tries = 0
|
||||
const interval = setInterval(() => {
|
||||
if (tries > 100) {
|
||||
clearInterval(interval)
|
||||
resolve(false)
|
||||
}
|
||||
if (svgUrlCache[url] !== 1) {
|
||||
svgDoc = svgUrlCache[url]
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
} else {
|
||||
tries++
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
} else {
|
||||
svgDoc = svgUrlCache[url] ?? `<svg xmlns="http://www.w3.org/2000/svg"></svg>`
|
||||
}
|
||||
} else {
|
||||
svgUrlCache[url] = 1
|
||||
const response = await fetch(url)
|
||||
const svgText = await response.text()
|
||||
svgDoc = domParser.parseFromString(svgText, 'image/svg+xml')
|
||||
svgUrlCache[url] = svgDoc
|
||||
}
|
||||
|
||||
const svgDoc = domParser.parseFromString(svgText, 'image/svg+xml')
|
||||
// @ts-ignore
|
||||
const symbol = svgDoc.getElementById(symbolId)
|
||||
|
||||
if (!symbol) {
|
||||
console.debug('Openreplay: Symbol not found in SVG.')
|
||||
return
|
||||
return ''
|
||||
}
|
||||
|
||||
if (mode === 'inline') {
|
||||
|
|
@ -136,10 +167,14 @@ export default abstract class Observer {
|
|||
private readonly indexes: Array<number> = []
|
||||
private readonly attributesMap: Map<number, Set<string>> = new Map()
|
||||
private readonly textSet: Set<number> = new Set()
|
||||
private readonly disableSprites: boolean = false
|
||||
private readonly domParser = new DOMParser()
|
||||
constructor(
|
||||
protected readonly app: App,
|
||||
protected readonly isTopContext = false,
|
||||
options: { disableSprites: boolean } = { disableSprites: false },
|
||||
) {
|
||||
this.disableSprites = options.disableSprites
|
||||
this.observer = createMutationObserver(
|
||||
this.app.safe((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
|
|
@ -249,8 +284,8 @@ export default abstract class Observer {
|
|||
this.app.send(RemoveNodeAttribute(id, name))
|
||||
}
|
||||
|
||||
if (isUseElement(node) && name === 'href') {
|
||||
parseUseEl(node, 'svgtext')
|
||||
if (isUseElement(node) && name === 'href' && !this.disableSprites) {
|
||||
parseUseEl(node, 'svgtext', this.domParser)
|
||||
.then((svgData) => {
|
||||
if (svgData) {
|
||||
this.app.send(SetNodeAttribute(id, name, `_$OPENREPLAY_SPRITE$_${svgData}`))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { IN_BROWSER, hasOpenreplayAttribute, canAccessIframe } from '../../utils
|
|||
|
||||
export interface Options {
|
||||
captureIFrames: boolean
|
||||
disableSprites: boolean
|
||||
}
|
||||
|
||||
type Context = Window & typeof globalThis
|
||||
|
|
@ -24,14 +25,16 @@ export default class TopObserver extends Observer {
|
|||
readonly app: App
|
||||
|
||||
constructor(params: { app: App; options: Partial<Options> }) {
|
||||
super(params.app, true)
|
||||
this.app = params.app
|
||||
this.options = Object.assign(
|
||||
const opts = Object.assign(
|
||||
{
|
||||
captureIFrames: true,
|
||||
disableSprites: false,
|
||||
},
|
||||
params.options,
|
||||
)
|
||||
super(params.app, true, opts)
|
||||
this.app = params.app
|
||||
this.options = opts
|
||||
// IFrames
|
||||
this.app.nodes.attachNodeCallback((node) => {
|
||||
if (
|
||||
|
|
|
|||
6
tracker/tracker/src/main/entry.ts
Normal file
6
tracker/tracker/src/main/entry.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import TrackerClass from './index.js'
|
||||
|
||||
export { default as App } from './app/index.js'
|
||||
export { SanitizeLevel, Messages, Options } from './index.js'
|
||||
export { default as tracker } from './singleton.js'
|
||||
export default TrackerClass
|
||||
|
|
@ -109,7 +109,7 @@ export default class API {
|
|||
private readonly app: App | null = null
|
||||
private readonly crossdomainMode: boolean = false
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
constructor(public readonly options: Partial<Options>) {
|
||||
this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled)
|
||||
if (!IN_BROWSER || !processOptions(options)) {
|
||||
return
|
||||
|
|
@ -287,7 +287,7 @@ export default class API {
|
|||
this.app.restartCanvasTracking()
|
||||
}
|
||||
|
||||
use<T>(fn: (app: App | null, options?: Options) => T): T {
|
||||
use<T>(fn: (app: App | null, options?: Partial<Options>) => T): T {
|
||||
return fn(this.app, this.options)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ export class StringDictionary {
|
|||
|
||||
getKey = (str: string): [string, boolean] => {
|
||||
let isNew = false
|
||||
if (!this.backDict[str]) {
|
||||
const safeKey = `__${str}`
|
||||
if (!this.backDict[safeKey]) {
|
||||
isNew = true
|
||||
this.backDict[str] = `${this.getPageNo() ?? 0}_${this.idx}`
|
||||
this.backDict[safeKey] = `${this.getPageNo() ?? 0}_${this.idx}`
|
||||
this.idx += 1
|
||||
}
|
||||
return [this.backDict[str], isNew]
|
||||
return [this.backDict[safeKey], isNew]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
ticks = frames = undefined
|
||||
})
|
||||
|
||||
app.ticker.attach(sendPerformanceTrack, 40, false)
|
||||
app.ticker.attach(sendPerformanceTrack, 165, false)
|
||||
|
||||
if (document.hidden !== undefined) {
|
||||
app.attachEventListener(
|
||||
|
|
|
|||
352
tracker/tracker/src/main/singleton.ts
Normal file
352
tracker/tracker/src/main/singleton.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import Tracker, { App, Options } from './index.js'
|
||||
import { IN_BROWSER } from './utils.js'
|
||||
import type { StartOptions, StartPromiseReturn } from './app/index.js'
|
||||
|
||||
class TrackerSingleton {
|
||||
private instance: Tracker | null = null
|
||||
private isConfigured = false
|
||||
|
||||
/**
|
||||
* Call this method once to create tracker configuration
|
||||
* @param options {Object} Check available options:
|
||||
* https://docs.openreplay.com/en/sdk/constructor/#initialization-options
|
||||
*/
|
||||
configure(options: Partial<Options>): void {
|
||||
if (!IN_BROWSER) {
|
||||
return
|
||||
}
|
||||
if (this.isConfigured) {
|
||||
console.warn(
|
||||
'OpenReplay: Tracker is already configured. You should only call configure once.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.projectKey) {
|
||||
console.error('OpenReplay: Missing required projectKey option')
|
||||
return
|
||||
}
|
||||
|
||||
this.instance = new Tracker(options)
|
||||
this.isConfigured = true
|
||||
}
|
||||
|
||||
get options(): Partial<Options> | null {
|
||||
return this.instance?.options || null
|
||||
}
|
||||
|
||||
start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
|
||||
if (!IN_BROWSER) {
|
||||
return Promise.resolve({ success: false, reason: 'Not in browser environment' })
|
||||
}
|
||||
|
||||
if (!this.ensureConfigured()) {
|
||||
return Promise.resolve({ success: false, reason: 'Tracker not configured' })
|
||||
}
|
||||
|
||||
return (
|
||||
this.instance?.start(startOpts) ||
|
||||
Promise.resolve({ success: false, reason: 'Tracker not initialized' })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the session and return sessionHash
|
||||
* (which can be used to stitch sessions together)
|
||||
* */
|
||||
stop(): string | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.stop()
|
||||
}
|
||||
|
||||
setUserID(id: string): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.setUserID(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for the current session
|
||||
*
|
||||
* Make sure that its configured in project settings first
|
||||
*
|
||||
* Read more: https://docs.openreplay.com/en/installation/metadata/
|
||||
*/
|
||||
setMetadata(key: string, value: string): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.setMetadata(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns full URL for the current session
|
||||
*/
|
||||
getSessionURL(options?: { withCurrentTime?: boolean }): string | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.getSessionURL(options)
|
||||
}
|
||||
|
||||
getSessionID(): string | null | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance.getSessionID()
|
||||
}
|
||||
|
||||
getSessionToken(): string | null | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance.getSessionToken()
|
||||
}
|
||||
|
||||
event(key: string, payload: any = null, issue = false): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.event(key, payload, issue)
|
||||
}
|
||||
|
||||
issue(key: string, payload: any = null): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.issue(key, payload)
|
||||
}
|
||||
|
||||
handleError(
|
||||
e: Error | ErrorEvent | PromiseRejectionEvent,
|
||||
metadata: Record<string, any> = {},
|
||||
): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.handleError(e, metadata)
|
||||
}
|
||||
|
||||
isFlagEnabled(flagName: string): boolean {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.instance.isFlagEnabled(flagName)
|
||||
}
|
||||
|
||||
onFlagsLoad(...args: Parameters<Tracker['onFlagsLoad']>): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.onFlagsLoad(...args)
|
||||
}
|
||||
|
||||
clearPersistFlags(): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.clearPersistFlags()
|
||||
}
|
||||
|
||||
reloadFlags(): Promise<void> | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.reloadFlags()
|
||||
}
|
||||
|
||||
getFeatureFlag(flagName: string) {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.getFeatureFlag(flagName)
|
||||
}
|
||||
|
||||
getAllFeatureFlags() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.getAllFeatureFlags()
|
||||
}
|
||||
|
||||
restartCanvasTracking(): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.restartCanvasTracking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the anonymous user ID
|
||||
*/
|
||||
setUserAnonymousID(id: string): void {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.instance.setUserAnonymousID(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tracker is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.instance.isActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Tracker instance
|
||||
*
|
||||
* Use when you need access to methods not exposed by the singleton
|
||||
*/
|
||||
getInstance(): Tracker | null {
|
||||
if (!this.ensureConfigured() || !IN_BROWSER) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* */
|
||||
coldStart(startOpts?: Partial<StartOptions>, conditional?: boolean) {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.coldStart(startOpts, conditional)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* msg direction is "down" (incoming) by default
|
||||
*
|
||||
* @returns {(msgType: string, data: string, dir: 'up' | 'down') => void}
|
||||
* */
|
||||
trackWs(
|
||||
channelName: string,
|
||||
): ((msgType: string, data: string, dir: 'up' | 'down') => void) | undefined {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return () => {} // Return no-op function
|
||||
}
|
||||
|
||||
return this.instance.trackWs(channelName)
|
||||
}
|
||||
|
||||
private ensureConfigured() {
|
||||
if (!this.isConfigured && IN_BROWSER) {
|
||||
console.warn(
|
||||
'OpenReplay: Tracker must be configured before use. Call tracker.configure({projectKey: "your-project-key"}) first.',
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
use<T>(fn: (app: App | null, options?: Partial<Options>) => T): T {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return fn(null)
|
||||
}
|
||||
|
||||
return this.instance.use(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts offline session recording. Keep in mind that only user device time will be used for timestamps.
|
||||
* (no backend delay sync)
|
||||
*
|
||||
* @param {Object} startOpts - options for session start, same as .start()
|
||||
* @param {Function} onSessionSent - callback that will be called once session is fully sent
|
||||
* @returns methods to manipulate buffer:
|
||||
*
|
||||
* saveBuffer - to save it in localStorage
|
||||
*
|
||||
* getBuffer - returns current buffer
|
||||
*
|
||||
* setBuffer - replaces current buffer with given
|
||||
* */
|
||||
startOfflineRecording(...args: Parameters<Tracker['startOfflineRecording']>) {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.startOfflineRecording(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 into service worker successfully
|
||||
* @reject {string} - error message
|
||||
* */
|
||||
uploadOfflineRecording() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.uploadOfflineRecording()
|
||||
}
|
||||
|
||||
forceFlushBatch() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return
|
||||
}
|
||||
|
||||
return this.instance.forceFlushBatch()
|
||||
}
|
||||
|
||||
getSessionInfo() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance.getSessionInfo()
|
||||
}
|
||||
|
||||
getTabId() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance.getTabId()
|
||||
}
|
||||
|
||||
getUxId() {
|
||||
if (!IN_BROWSER || !this.ensureConfigured() || !this.instance) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.instance.getUxId()
|
||||
}
|
||||
}
|
||||
|
||||
const tracker = new TrackerSingleton()
|
||||
|
||||
export default tracker
|
||||
82
tracker/tracker/src/tests/singleton.test.ts
Normal file
82
tracker/tracker/src/tests/singleton.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, test, jest, beforeAll, afterAll } from '@jest/globals'
|
||||
import singleton from "../main/singleton";
|
||||
|
||||
jest.mock('@medv/finder', () => ({ default: jest.fn(() => 'mocked network-proxy content') }));
|
||||
jest.mock('@openreplay/network-proxy', () => ({ default: jest.fn(() => 'mocked network-proxy content') }));
|
||||
|
||||
const methods = [
|
||||
'onFlagsLoad',
|
||||
'isFlagEnabled',
|
||||
'clearPersistFlags',
|
||||
'reloadFlags',
|
||||
'getFeatureFlag',
|
||||
'getAllFeatureFlags',
|
||||
'restartCanvasTracking',
|
||||
'use',
|
||||
'isActive',
|
||||
'trackWs',
|
||||
'start',
|
||||
'coldStart',
|
||||
'startOfflineRecording',
|
||||
'uploadOfflineRecording',
|
||||
'stop',
|
||||
'forceFlushBatch',
|
||||
'getSessionToken',
|
||||
'getSessionInfo',
|
||||
'getSessionID',
|
||||
'getTabId',
|
||||
'getUxId',
|
||||
'getSessionURL',
|
||||
'setUserID',
|
||||
'setUserAnonymousID',
|
||||
'setMetadata',
|
||||
'event',
|
||||
'issue',
|
||||
'handleError',
|
||||
]
|
||||
|
||||
describe('Singleton Testing', () => {
|
||||
const options = {
|
||||
projectKey: 'test-project-key',
|
||||
ingestPoint: 'test-ingest-point',
|
||||
respectDoNotTrack: false,
|
||||
__DISABLE_SECURE_MODE: true
|
||||
};
|
||||
beforeAll(() => {
|
||||
// Mock the performance object and its timing property
|
||||
Object.defineProperty(window, 'performance', {
|
||||
value: {
|
||||
timing: {},
|
||||
now: jest.fn(() => 1000), // Mock performance.now() if needed
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, 'Worker', {
|
||||
value: jest.fn(() => 'mocked worker content')
|
||||
})
|
||||
global.IntersectionObserver = jest.fn(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
thresholds: [0],
|
||||
takeRecords: jest.fn(() => []),
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up the mock after tests if needed
|
||||
delete window.performance;
|
||||
delete window.Worker;
|
||||
delete global.IntersectionObserver;
|
||||
});
|
||||
|
||||
test('Singleton methods are compatible with Class', () => {
|
||||
singleton.configure(options);
|
||||
|
||||
methods.forEach(method => {
|
||||
console.log(method);
|
||||
expect(singleton[method]).toBeDefined();
|
||||
});
|
||||
})
|
||||
})
|
||||
|
|
@ -52,10 +52,13 @@ export default class BatchWriter {
|
|||
this.url,
|
||||
]
|
||||
|
||||
const timestamp: Messages.Timestamp = [Messages.Type.Timestamp, this.timestamp]
|
||||
|
||||
const tabData: Messages.TabData = [Messages.Type.TabData, this.tabId]
|
||||
|
||||
this.writeType(batchMetadata)
|
||||
this.writeFields(batchMetadata)
|
||||
this.writeWithSize(timestamp as Message)
|
||||
this.writeWithSize(tabData as Message)
|
||||
this.isEmpty = true
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue