From 1ab7d0ad7f685e22729b7d5e95a561f9a1677c2b Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Wed, 5 Mar 2025 13:39:35 +0100 Subject: [PATCH] tracker: introduce singleton approach for tracker --- tracker/tracker/package.json | 15 +- tracker/tracker/rollup.config.js | 51 ++- tracker/tracker/src/main/entry.ts | 5 + tracker/tracker/src/main/index.ts | 4 +- tracker/tracker/src/main/singleton.ts | 352 ++++++++++++++++++++ tracker/tracker/src/tests/singleton.test.ts | 82 +++++ 6 files changed, 476 insertions(+), 33 deletions(-) create mode 100644 tracker/tracker/src/main/entry.ts create mode 100644 tracker/tracker/src/main/singleton.ts create mode 100644 tracker/tracker/src/tests/singleton.test.ts diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index ba89c0826..ea0fbec48 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -15,13 +15,18 @@ "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": [ @@ -29,9 +34,9 @@ "dist/cjs/**/*", "dist/types/**/*" ], - "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", diff --git a/tracker/tracker/rollup.config.js b/tracker/tracker/rollup.config.js index 9b09c574c..a2e643984 100644 --- a/tracker/tracker/rollup.config.js +++ b/tracker/tracker/rollup.config.js @@ -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() { diff --git a/tracker/tracker/src/main/entry.ts b/tracker/tracker/src/main/entry.ts new file mode 100644 index 000000000..ef2a17b8b --- /dev/null +++ b/tracker/tracker/src/main/entry.ts @@ -0,0 +1,5 @@ +import TrackerClass from './index.js' + +export { SanitizeLevel, Messages, Options } from './index.js' +export { default as tracker } from './singleton.js' +export default TrackerClass diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index e1863a6d7..765f30d94 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -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) { 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(fn: (app: App | null, options?: Options) => T): T { + use(fn: (app: App | null, options?: Partial) => T): T { return fn(this.app, this.options) } diff --git a/tracker/tracker/src/main/singleton.ts b/tracker/tracker/src/main/singleton.ts new file mode 100644 index 000000000..4e749b8c2 --- /dev/null +++ b/tracker/tracker/src/main/singleton.ts @@ -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): 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 | null { + return this.instance?.options || null + } + + start(startOpts?: Partial): Promise { + 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 = {}, + ): 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): 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 | 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, 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(fn: (app: App | null, options?: Partial) => 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) { + 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 diff --git a/tracker/tracker/src/tests/singleton.test.ts b/tracker/tracker/src/tests/singleton.test.ts new file mode 100644 index 000000000..1587985f8 --- /dev/null +++ b/tracker/tracker/src/tests/singleton.test.ts @@ -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(); + }); + }) +}) \ No newline at end of file