Compare commits

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

12 commits

Author SHA1 Message Date
nick-delirium
60ed3add79
tracker: 15.1.2 small fixes for batch forming 2025-04-02 15:21:28 +02:00
Андрей Бабушкин
96f7a25641
combine in 1 line 2025-04-02 15:11:04 +02:00
Андрей Бабушкин
71e0c6915e
add timestamp to prepare method 2025-04-02 15:10:53 +02:00
Андрей Бабушкин
8ecc67c745
increase perfomance ticker and remove empty batches 2025-04-02 15:10:27 +02:00
nick-delirium
999e5cba3a
tracker: 15.1.1 fix attributeSender key gen 2025-03-17 11:32:51 +01:00
nick-delirium
9c6ef3182d
tracker: 15.1.0 2025-03-06 09:33:15 +01:00
nick-delirium
1f77d432ca
tracker: export tracker App type from entry.ts 2025-03-06 09:23:28 +01:00
nick-delirium
2dcedc9137
tracker: introduce singleton approach for tracker 2025-03-05 13:39:35 +01:00
nick-delirium
6765dadf09
tracker: move domparser location inside observer 2025-03-05 13:39:09 +01:00
nick-delirium
ef6f18d0f3
tracker: fix spritemap parser, add svgdoc cache 2025-03-04 16:37:40 +01:00
nick-delirium
fb550a26dc
tracker: changelog, release 15.0.6 2025-03-03 17:13:59 +01:00
Aspyryan
a8c68e413c
Running buffer slicing when browser is idle (#3050)
* Fixed tracker uploadOfflineRecording

* Make FlushBuffer perform slicing when browser is idle

* Use map function to cast away proxy objects in flushBuffer
2025-03-03 17:10:03 +01:00
15 changed files with 595 additions and 63 deletions

View file

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

View file

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

View file

@ -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() {

View file

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

View file

@ -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}`))

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View 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();
});
})
})

View file

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