tracker: introduce singleton approach for tracker
This commit is contained in:
parent
2ee535f213
commit
1ab7d0ad7f
6 changed files with 476 additions and 33 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
5
tracker/tracker/src/main/entry.ts
Normal file
5
tracker/tracker/src/main/entry.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
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();
|
||||
});
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue