Compare commits

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

10 commits

Author SHA1 Message Date
nick-delirium
53cf91bb16
tracker: more connectors for tracker and sdk 2025-03-19 16:44:02 +01:00
nick-delirium
fa2923b83d
tracker: user/device id hardreset method 2025-03-18 17:41:22 +01:00
nick-delirium
529965486c
tracker: move userid to common props, use email/device as dist id 2025-03-18 17:20:35 +01:00
nick-delirium
9f51ab85da
tracker: use shared props list as restricted for custom properties 2025-03-18 16:35:01 +01:00
nick-delirium
226fc867c0
tracker: move sdk, add people/event trackers, add unit tests 2025-03-18 15:23:47 +01:00
nick-delirium
2a1c28cc49
tracker: start tracker sdk 2025-03-17 16:43:10 +01:00
nick-delirium
4c967d4bc1
ui: update tracker import examples 2025-03-17 13:42:34 +01:00
Alexander
3fdf799bd7 feat(http): unsupported tracker error with projectID in logs 2025-03-17 13:32:00 +01:00
nick-delirium
9aca716e6b
tracker: 16.0.2 fix str dictionary keys 2025-03-17 11:25:54 +01:00
Shekar Siri
cf9ecdc9a4 refactor(searchStore): reformat filterMap function parameters
- Reformat the parameters of the filterMap function for better readability.
- Comment out the fetchSessions call in clearSearch method to avoid unnecessary session fetch.
2025-03-14 19:47:42 +01:00
30 changed files with 1589 additions and 71 deletions

View file

@ -135,11 +135,6 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
// Add tracker version to context
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
if err := validateTrackerVersion(req.TrackerVersion); err != nil {
e.log.Error(r.Context(), "unsupported tracker version: %s, err: %s", req.TrackerVersion, err)
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("please upgrade the tracker version"), startTime, r.URL.Path, bodySize)
return
}
// Handler's logic
if req.ProjectKey == nil {
@ -162,6 +157,13 @@ func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Req
// Add projectID to context
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
// Validate tracker version
if err := validateTrackerVersion(req.TrackerVersion); err != nil {
e.log.Error(r.Context(), "unsupported tracker version: %s, err: %s", req.TrackerVersion, err)
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("please upgrade the tracker version"), startTime, r.URL.Path, bodySize)
return
}
// Check if the project supports mobile sessions
if !p.IsWeb() {
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)

View file

@ -16,10 +16,10 @@ function ProfilerDoc() {
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const usage = `import OpenReplay from '@openreplay/tracker';
const usage = `import { tracker } from '@openreplay/tracker';
import trackerProfiler from '@openreplay/tracker-profiler';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()
@ -29,10 +29,12 @@ export const profiler = tracker.use(trackerProfiler());
const fn = profiler('call_name')(() => {
//...
}, thisArg); // thisArg is optional`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerProfiler from '@openreplay/tracker-profiler/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
//...

View file

@ -7,17 +7,19 @@ import { useTranslation } from 'react-i18next';
function AssistNpm(props) {
const { t } = useTranslation();
const usage = `import OpenReplay from '@openreplay/tracker';
const usage = `import { tracker } from '@openreplay/tracker';
import trackerAssist from '@openreplay/tracker-assist';
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${props.projectKey}',
});
tracker.start()
tracker.use(trackerAssist(options)); // check the list of available options below`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerAssist from '@openreplay/tracker-assist/cjs';
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${props.projectKey}'
});
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below

View file

@ -14,19 +14,20 @@ function GraphQLDoc() {
const projectKey = siteId
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const usage = `import OpenReplay from '@openreplay/tracker';
const usage = `import { tracker } from '@openreplay/tracker';
import trackerGraphQL from '@openreplay/tracker-graphql';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()
//...
export const recordGraphQL = tracker.use(trackerGraphQL());`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
const usageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
//...

View file

@ -15,20 +15,21 @@ function MobxDoc() {
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const mobxUsage = `import OpenReplay from '@openreplay/tracker';
const mobxUsage = `import { tracker } from '@openreplay/tracker';
import trackerMobX from '@openreplay/tracker-mobx';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below
tracker.start();
`;
const mobxUsageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
const mobxUsageCjs = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerMobX from '@openreplay/tracker-mobx/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below

View file

@ -16,10 +16,10 @@ function NgRxDoc() {
: sites[0]?.projectKey;
const usage = `import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import OpenReplay from '@openreplay/tracker';
import { tracker } from '@openreplay/tracker';
import trackerNgRx from '@openreplay/tracker-ngrx';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()
@ -32,10 +32,11 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
export class AppModule {}`;
const usageCjs = `import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import OpenReplay from '@openreplay/tracker/cjs';
import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerNgRx from '@openreplay/tracker-ngrx/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
//...

View file

@ -17,10 +17,10 @@ function PiniaDoc() {
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker';
import { tracker } from '@openreplay/tracker';
import trackerVuex from '@openreplay/tracker-vuex';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()

View file

@ -16,10 +16,10 @@ function ReduxDoc() {
: sites[0]?.projectKey;
const usage = `import { applyMiddleware, createStore } from 'redux';
import OpenReplay from '@openreplay/tracker';
import { tracker } from '@openreplay/tracker';
import trackerRedux from '@openreplay/tracker-redux';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()
@ -29,10 +29,11 @@ const store = createStore(
applyMiddleware(tracker.use(trackerRedux(<options>))) // check list of available options below
);`;
const usageCjs = `import { applyMiddleware, createStore } from 'redux';
import OpenReplay from '@openreplay/tracker/cjs';
import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerRedux from '@openreplay/tracker-redux/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
//...

View file

@ -16,10 +16,10 @@ function VueDoc() {
: sites[0]?.projectKey;
const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker';
import { tracker } from '@openreplay/tracker';
import trackerVuex from '@openreplay/tracker-vuex';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
tracker.start()
@ -29,10 +29,11 @@ const store = new Vuex.Store({
plugins: [tracker.use(trackerVuex(<options>))] // check list of available options below
});`;
const usageCjs = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker/cjs';
import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerVuex from '@openreplay/tracker-vuex/cjs';
//...
const tracker = new OpenReplay({
tracker.configure({
projectKey: '${projectKey}'
});
//...

View file

@ -16,11 +16,10 @@ function ZustandDoc(props) {
: sites[0]?.projectKey;
const usage = `import create from "zustand";
import Tracker from '@openreplay/tracker';
import { tracker } from '@openreplay/tracker';
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand';
const tracker = new Tracker({
tracker.configure({
projectKey: ${projectKey},
});
@ -43,11 +42,12 @@ const useBearStore = create(
)
`;
const usageCjs = `import create from "zustand";
import Tracker from '@openreplay/tracker/cjs';
import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
import trackerZustand, { StateLogger } from '@openreplay/tracker-zustand/cjs';
const tracker = new Tracker({
tracker.configure({
projectKey: ${projectKey},
});

View file

@ -7,16 +7,17 @@ import stl from './installDocs.module.css';
import { useTranslation } from 'react-i18next';
const installationCommand = 'npm i @openreplay/tracker';
const usageCode = `import Tracker from '@openreplay/tracker';
const usageCode = `import { tracker } from '@openreplay/tracker';
const tracker = new Tracker({
tracker.configure({
projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest",
});
tracker.start()`;
const usageCodeSST = `import Tracker from '@openreplay/tracker/cjs';
const usageCodeSST = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
const tracker = new Tracker({
tracker.configure({
projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest",
});

View file

@ -5,17 +5,18 @@ import stl from './installDocs.module.css';
import { useTranslation } from 'react-i18next';
const installationCommand = 'npm i @openreplay/tracker';
const usageCode = `import Tracker from '@openreplay/tracker';
const usageCode = `import { tracker } from '@openreplay/tracker';
const tracker = new Tracker({
tracker.configure({
projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest",
});
tracker.start()`;
const usageCodeSST = `import Tracker from '@openreplay/tracker/cjs';
const usageCodeSST = `import { tracker } from '@openreplay/tracker/cjs';
// alternatively you can use dynamic import without /cjs suffix to prevent issues with window scope
const tracker = new Tracker({
tracker.configure({
projectKey: "PROJECT_KEY",
ingestPoint: "https://${window.location.hostname}/ingest",
});

View file

@ -28,18 +28,18 @@ export const checkValues = (key: any, value: any) => {
};
export const filterMap = ({
category,
value,
key,
operator,
sourceOperator,
source,
custom,
isEvent,
filters,
sort,
order
}: any) => ({
category,
value,
key,
operator,
sourceOperator,
source,
custom,
isEvent,
filters,
sort,
order
}: any) => ({
value: checkValues(key, value),
custom,
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
@ -254,7 +254,7 @@ class SearchStore {
this.savedSearch = new SavedSearch({});
sessionStore.clearList();
void this.fetchSessions(true);
// void this.fetchSessions(true);
}
async checkForLatestSessionCount(): Promise<void> {

View file

@ -1,3 +1,7 @@
## 16.0.2
- fix attributeSender key generation to prevent calling native methods on objects
## 16.0.1
- drop computing ts digits

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "16.0.1",
"version": "16.0.2",
"keywords": [
"logging",
"replay"

View file

@ -1,6 +1,7 @@
import TrackerClass from './index.js'
import TrackerClass from './index'
export { default as App } from './app/index.js'
export { SanitizeLevel, Messages, Options } from './index.js'
export { default as tracker } from './singleton.js'
export { default as App } from './app/index'
export { default as tracker, default as openReplay } from './singleton'
export { SanitizeLevel, Messages, Options } from './index'
export { default as Analytics } from './modules/analytics/index'
export default TrackerClass

View file

@ -1,4 +1,4 @@
import App from './app/index.js'
import App from './app/index'
export { default as App } from './app/index.js'
@ -28,6 +28,7 @@ import Network from './modules/network.js'
import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
import Selection from './modules/selection.js'
import Tabs from './modules/tabs.js'
import AnalyticsSDK from './modules/analytics/index.js'
import { IN_BROWSER, deprecationWarn, DOCS_HOST, inIframe } from './utils.js'
import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js'
@ -107,6 +108,7 @@ export default class API {
public featureFlags: FeatureFlags
private readonly app: App | null = null
public readonly analytics: AnalyticsSDK | null = null
private readonly crossdomainMode: boolean = false
constructor(public readonly options: Partial<Options>) {
@ -178,6 +180,13 @@ export default class API {
this.signalStartIssue,
this.crossdomainMode,
)
this.analytics = new AnalyticsSDK(
options.localStorage ?? localStorage,
options.sessionStorage ?? sessionStorage,
this.getAnalyticsToken,
this.app?.timestamp ?? Date.now,
this.setUserID,
)
this.app = app
if (!this.crossdomainMode) {
// no need to send iframe viewport data since its a node for us
@ -317,6 +326,9 @@ export default class API {
if (this.app === null) {
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
}
if (startOpts?.userID) {
this.analytics?.people.identify(startOpts.userID, { fromTracker: true })
}
return this.app.start(startOpts)
} else {
return Promise.reject('Trying to start not in browser.')
@ -452,6 +464,7 @@ export default class API {
setUserID(id: string): void {
if (typeof id === 'string' && this.app !== null) {
this.app.session.setUserID(id)
this.analytics?.people.identify(id, { fromTracker: true })
}
}
@ -528,4 +541,19 @@ export default class API {
}
}
}
private analyticsToken: string | null = null
/**
* Use custom token for analytics events without session recording
* */
public setAnalyticsToken = (token: string) => {
this.analyticsToken = token
}
public getAnalyticsToken = () => {
if (this.analyticsToken) {
return this.analyticsToken
} else {
return this.app?.session.getSessionToken() ?? ''
}
}
}

View file

@ -0,0 +1,136 @@
import SharedProperties from './sharedProperties.js'
import { isObject } from './utils.js'
const maxProperties = 100;
const maxPropLength = 100;
export default class Events {
queue: Record<string, any> = []
sendInterval: ReturnType<typeof setInterval> | null = null
ownProperties: Record<string, any> = {}
constructor(
private readonly sharedProperties: SharedProperties,
private readonly getToken: () => string,
private readonly getTimestamp: () => number,
private readonly batchInterval = 5000,
) {
this.sendInterval = setInterval(() => {
void this.sendBatch()
}, batchInterval)
}
/**
* Add event to batch with option to send it immediately,
* properties are optional and will not be saved as super prop
* */
sendEvent = (
eventName: string,
properties?: Record<string, any>,
options?: { send_immediately: boolean },
) => {
// add properties
const eventProps = {}
if (properties) {
if (!isObject(properties)) {
throw new Error('Properties must be an object')
}
Object.entries(properties).forEach(([key, value]) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key)) {
eventProps[key] = value
}
})
}
const event = {
event: eventName,
properties: { ...this.sharedProperties.all, ...this.ownProperties, ...eventProps },
timestamp: this.getTimestamp(),
}
if (options?.send_immediately) {
void this.sendSingle(event)
} else {
this.queue.push(event)
}
}
sendBatch = async () => {
if (this.queue.length === 0) {
return
}
const headers = {
Authorization: `Bearer ${this.getToken()}`,
'Content-Type': 'application/json',
}
// await fetch blah blah + token
}
sendSingle = async (event) => {
const headers = {
Authorization: `Bearer ${this.getToken()}`,
'Content-Type': 'application/json',
}
// await fetch blah blah + token
}
/**
* creates super property for all events
* TODO: export as tracker.register
* */
setProperty = (nameOrProperties: Record<string, any> | string, value?: any) => {
if (isObject(nameOrProperties)) {
Object.entries(nameOrProperties).forEach(([key, val]) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key)) {
this.ownProperties[key] = val
}
})
}
if (typeof nameOrProperties === 'string' && value !== undefined) {
if (!this.sharedProperties.defaultPropertyKeys.includes(nameOrProperties)) {
this.ownProperties[nameOrProperties] = value
}
}
}
/**
* set super property only if it doesn't exist
* TODO: export as register_once
* */
setPropertiesOnce = (nameOrProperties: Record<string, any> | string, value?: any) => {
if (isObject(nameOrProperties)) {
Object.entries(nameOrProperties).forEach(([key, val]) => {
if (!this.ownProperties[key] && !reservedProps.includes(key)) {
this.ownProperties[key] = val
}
})
}
if (typeof nameOrProperties === 'string' && value !== undefined) {
if (!this.ownProperties[nameOrProperties] && !reservedProps.includes(nameOrProperties)) {
this.ownProperties[nameOrProperties] = value
}
}
}
/**
* removes properties from list
* TODO: export as unregister
* */
unsetProperties = (properties: string | string[]) => {
if (Array.isArray(properties)) {
properties.forEach((key) => {
if (this.ownProperties[key] && !reservedProps.includes(key)) {
delete this.ownProperties[key]
}
})
} else if (this.ownProperties[properties] && !reservedProps.includes(properties)) {
delete this.ownProperties[properties]
}
}
generateDynamicProperties = () => {
return {
$auto_captured: false,
$current_url: window.location.href,
$referrer: document.referrer,
}
}
}

View file

@ -0,0 +1,35 @@
import SharedProperties from './sharedProperties.js'
import type { StorageLike } from './sharedProperties.js'
import Events from './events.js'
import People from './people.js'
export default class Analytics {
public readonly events: Events
public readonly sharedProperties: SharedProperties
public readonly people: People
/**
* @param localStorage Class or Object that implements Storage-like interface that stores
* values persistently like window.localStorage or any other file-based storage
*
* @param sessionStorage Class or Object that implements Storage-like interface that stores values
* on per-session basis like window.sessionStorage or any other in-memory storage
*
* @param getToken Function that returns token to bind events to a session
*
* @param getTimestamp returns current timestamp
*
* @param setUserId callback for people.identify
* */
constructor(
private readonly localStorage: StorageLike,
private readonly sessionStorage: StorageLike,
private readonly getToken: () => string,
private readonly getTimestamp: () => number,
private readonly setUserId: (user_id: string) => void,
) {
this.sharedProperties = new SharedProperties(localStorage, sessionStorage)
this.events = new Events(this.sharedProperties, getToken, getTimestamp)
this.people = new People(this.sharedProperties, getToken, getTimestamp, setUserId)
}
}

View file

@ -0,0 +1,130 @@
import SharedProperties from './sharedProperties.js'
import { isObject } from './utils.js'
export default class People {
ownProperties: Record<string, any> = {}
constructor(
private readonly sharedProperties: SharedProperties,
private readonly getToken: () => string,
private readonly getTimestamp: () => number,
private readonly onId: (user_id: string) => void,
) {}
identify = (user_id: string, options?: { fromTracker: boolean }) => {
this.sharedProperties.setUserId(user_id)
if (!options?.fromTracker) {
this.onId(user_id)
}
// TODO: fetch endpoint when it will be here
}
// add "hard" flag to force generate device id as well ?
reset = () => {
this.sharedProperties.resetUserId()
this.ownProperties = {}
}
get user_id() {
return this.sharedProperties.user_id
}
// TODO: what exactly we're removing here besides properties and id?
deleteUser = () => {
this.sharedProperties.setUserId(null)
this.ownProperties = {}
// TODO: fetch endpoint when it will be here
}
/**
* set ownProperties, overwriting entire object
*
* TODO: exported as people.set
* */
setProperties = (properties: Record<string, any>) => {
if (!isObject(properties)) {
throw new Error('Properties must be an object')
}
Object.entries(properties).forEach(([key, value]) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key)) {
this.ownProperties[key] = value
}
})
}
/**
* Set property if it doesn't exist yet
*
* TODO: exported as people.set_once
* */
setPropertiesOnce = (properties: Record<string, any>) => {
if (!isObject(properties)) {
throw new Error('Properties must be an object')
}
Object.entries(properties).forEach(([key, value]) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key) && !this.ownProperties[key]) {
this.ownProperties[key] = value
}
})
}
/**
* Add value to property (will turn string prop into array)
*
* TODO: exported as people.append
* */
appendValues = (key: string, value: string | number) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key) && this.ownProperties[key]) {
if (Array.isArray(this.ownProperties[key])) {
this.ownProperties[key].push(value)
} else {
this.ownProperties[key] = [this.ownProperties[key], value]
}
}
}
/**
* Add unique values to property (will turn string prop into array)
*
* TODO: exported as people.union
* */
appendUniqueValues = (key: string, value: string | number) => {
if (!this.ownProperties[key]) return
if (Array.isArray(this.ownProperties[key])) {
if (!this.ownProperties[key].includes(value)) {
this.appendValues(key, value)
}
} else if (this.ownProperties[key] !== value) {
this.appendValues(key, value)
}
}
/**
* Adds value (incl. negative) to existing numerical property
*
* TODO: exported as people.increment
* */
increment = (key: string, value: number) => {
if (!this.sharedProperties.defaultPropertyKeys.includes(key) && typeof this.ownProperties[key] === 'number') {
this.ownProperties[key] += value
}
}
fetchUserProperties = async () => {
if (!this.user_id) return
const userObj = {
user_id: this.user_id,
distinct_id: this.sharedProperties.distinctId,
properties: {
...this.sharedProperties.all,
...this.ownProperties,
},
}
const headers = {
Authorization: `Bearer ${this.getToken()}`,
}
// fetch user properties
}
}

View file

@ -0,0 +1,144 @@
import { getUTCOffsetString, uaParse } from './utils.js'
export interface StorageLike {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
}
const refKey = '$__initial_ref__$'
const distinctIdKey = '$__distinct_device_id__$'
const prefix = '$'
const searchEngineList = [
'google',
'bing',
'yahoo',
'baidu',
'yandex',
'duckduckgo',
'ecosia',
'ask',
'aol',
'wolframalpha',
'startpage',
'swisscows',
'qwant',
'lycos',
'dogpile',
'info',
'teoma',
'webcrawler',
'naver',
'seznam',
'perplexity',
]
export default class SharedProperties {
os: string
osVersion: string
browser: string
browserVersion: string
device: string
screenHeight: number
screenWidth: number
initialReferrer: string
utmSource: string | null
utmMedium: string | null
utmCampaign: string | null
deviceId: string
searchEngine: string | null
user_id: string | null = null
constructor(
private readonly localStorage: StorageLike,
private readonly sessionStorage: StorageLike,
) {
const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } =
uaParse(window)
this.os = os
this.osVersion = osVersion
this.browser = `${browser}`
this.browserVersion = `${browserVersion} (${browserMajorVersion})`
this.device = mobile ? 'Mobile' : 'Desktop'
this.screenHeight = height
this.screenWidth = width
this.initialReferrer = this.getReferrer()
const searchParams = new URLSearchParams(window.location.search)
this.utmSource = searchParams.get('utm_source') || null
this.utmMedium = searchParams.get('utm_medium') || null
this.utmCampaign = searchParams.get('utm_campaign') || null
this.deviceId = this.getDistinctDeviceId()
this.searchEngine = this.getSerachEngine(this.initialReferrer)
}
public get all() {
return {
[`${prefix}os`]: this.os,
[`${prefix}os_version`]: this.osVersion,
[`${prefix}browser`]: this.browser,
[`${prefix}browser_version`]: this.browserVersion,
[`${prefix}device`]: this.device,
[`${prefix}screen_height`]: this.screenHeight,
[`${prefix}screen_width`]: this.screenWidth,
[`${prefix}initial_referrer`]: this.initialReferrer,
[`${prefix}utm_source`]: this.utmSource,
[`${prefix}utm_medium`]: this.utmMedium,
[`${prefix}utm_campaign`]: this.utmCampaign,
[`${prefix}device_id`]: this.deviceId,
[`${prefix}user_id`]: this.user_id,
[`${prefix}distinct_id`]: this.user_id ?? this.deviceId,
[`${prefix}sdk_edition`]: 'web',
[`${prefix}sdk_version`]: 'TRACKER_VERSION',
[`${prefix}timezone`]: getUTCOffsetString(),
[`${prefix}search_engine`]: this.searchEngine,
}
}
setUserId = (user_id: string | null) => {
this.user_id = user_id
}
resetUserId = () => {
this.user_id = null
this.deviceId = this.getDistinctDeviceId(true)
}
public get defaultPropertyKeys() {
return Object.keys(this.all)
}
public get distinctId() {
return this.deviceId
}
private getDistinctDeviceId = (force?: boolean) => {
const potentialStored = this.localStorage.getItem(distinctIdKey)
if (potentialStored && !force) {
return potentialStored
} else {
const distinctId = `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
this.localStorage.setItem(distinctIdKey, distinctId)
return distinctId
}
}
private getReferrer = () => {
const potentialStored = this.sessionStorage.getItem(refKey)
if (potentialStored) {
return potentialStored
} else {
const ref = document.referrer
this.sessionStorage.setItem(refKey, ref)
return ref
}
}
private getSerachEngine = (ref: string) => {
for (const searchEngine of searchEngineList) {
if (ref.includes(searchEngine)) {
return searchEngine
}
}
return null
}
}

View file

@ -0,0 +1,201 @@
// @ts-nocheck
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'
import Events from '../events.js'
describe('Events', () => {
let mockSharedProperties
let events
let mockTimestamp
let mockToken
let mockGetTimestamp
let originalSetInterval
let originalClearInterval
let setIntervalMock
beforeEach(() => {
originalSetInterval = global.setInterval
originalClearInterval = global.clearInterval
setIntervalMock = jest.fn(() => 123)
global.setInterval = setIntervalMock
global.clearInterval = jest.fn()
mockTimestamp = 1635186000000 // Example timestamp
mockGetTimestamp = jest.fn(() => mockTimestamp)
mockSharedProperties = {
all: {
$__os: 'Windows 10',
$__browser: 'Chrome 91.0.4472.124 (91)',
$__device: 'Desktop',
$__screenHeight: 1080,
$__screenWidth: 1920,
$__initialReferrer: 'https://example.com',
$__utmSource: 'test_source',
$__utmMedium: 'test_medium',
$__utmCampaign: 'test_campaign',
$__deviceId: 'test-device-id',
},
}
mockToken = 'test-token-123'
events = new Events(mockSharedProperties, mockToken, mockGetTimestamp, 1000)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
})
afterEach(() => {
global.setInterval = originalSetInterval
global.clearInterval = originalClearInterval
if (events.sendInterval) {
clearInterval(events.sendInterval)
}
jest.restoreAllMocks()
})
test('constructor sets up event queue and batch interval', () => {
expect(events.queue).toEqual([])
expect(events.ownProperties).toEqual({})
expect(setIntervalMock).toHaveBeenCalledWith(expect.any(Function), 1000)
expect(events.sendInterval).toBe(123)
})
test('sendEvent adds event to queue with correct properties', () => {
events.sendEvent('test_event', { testProp: 'value' })
expect(events.queue).toHaveLength(1)
expect(events.queue[0]).toEqual({
event: 'test_event',
properties: {
...mockSharedProperties.all,
testProp: 'value',
},
timestamp: mockTimestamp,
})
})
test('sendEvent validates properties are objects', () => {
expect(() => {
events.sendEvent('test_event', 'not an object')
}).toThrow('Properties must be an object')
})
test('sendEvent with send_immediately option calls sendSingle', async () => {
const sendSingleSpy = jest.spyOn(events, 'sendSingle').mockResolvedValue(undefined)
await events.sendEvent('immediate_test', { testProp: 'value' }, { send_immediately: true })
expect(sendSingleSpy).toHaveBeenCalledWith({
event: 'immediate_test',
properties: {
...mockSharedProperties.all,
testProp: 'value',
},
timestamp: mockTimestamp,
})
expect(events.queue).toHaveLength(0) // Should not add to queue
})
test('sendBatch does nothing when queue is empty', async () => {
await events.sendBatch()
expect(global.fetch).not.toHaveBeenCalled()
})
test('setProperty sets a single property correctly', () => {
events.setProperty('testProp', 'value')
expect(events.ownProperties).toEqual({ testProp: 'value' })
events.setProperty('event_name', 'should not be set')
expect(events.ownProperties.event_name).toBeUndefined()
})
test('setProperty sets multiple properties from object', () => {
events.setProperty({
prop1: 'value1',
prop2: 'value2',
event_name: 'should not be set', // Reserved
})
expect(events.ownProperties).toEqual({
prop1: 'value1',
prop2: 'value2',
})
})
test('setPropertiesOnce only sets properties that do not exist', () => {
events.setProperty('existingProp', 'initial value')
events.setPropertiesOnce({
existingProp: 'new value',
newProp: 'value',
})
expect(events.ownProperties).toEqual({
existingProp: 'initial value', // Should not change
newProp: 'value', // Should be added
})
})
test('setPropertiesOnce sets a single property if it does not exist', () => {
events.setPropertiesOnce('newProp', 'value')
expect(events.ownProperties).toEqual({ newProp: 'value' })
events.setPropertiesOnce('newProp', 'new value')
expect(events.ownProperties).toEqual({ newProp: 'value' }) // Should not change
})
test('unsetProperties removes a single property', () => {
events.setProperty({
prop1: 'value1',
prop2: 'value2',
})
events.unsetProperties('prop1')
expect(events.ownProperties).toEqual({
prop2: 'value2',
})
})
test('unsetProperties removes multiple properties', () => {
events.setProperty({
prop1: 'value1',
prop2: 'value2',
prop3: 'value3',
})
events.unsetProperties(['prop1', 'prop3'])
expect(events.ownProperties).toEqual({
prop2: 'value2',
})
})
test('unsetProperties does not remove reserved properties', () => {
events.ownProperties.event_name = 'test'
events.unsetProperties('event_name')
expect(events.ownProperties.event_name).toBe('test')
})
test('events include both shared and own properties', () => {
events.setProperty('customProp', 'custom value')
events.sendEvent('test_event')
expect(events.queue[0].properties).toEqual({
...mockSharedProperties.all,
customProp: 'custom value',
})
})
test('event properties override own properties', () => {
events.setProperty('customProp', 'own value')
events.sendEvent('test_event', { customProp: 'event value' })
expect(events.queue[0].properties.customProp).toBe('event value')
})
})

View file

@ -0,0 +1,258 @@
// @ts-nocheck
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'
import People from '../people.js'
import * as utils from '../utils.js'
jest.spyOn(utils, 'isObject')
describe('People', () => {
let mockSharedProperties
let mockGetToken
let mockGetTimestamp
let people
beforeEach(() => {
// Mock shared properties
mockSharedProperties = {
all: {
$__os: 'Windows 10',
$__browser: 'Chrome 91.0.4472.124 (91)',
$__deviceId: 'test-device-id',
},
}
// Mock token and timestamp functions
mockGetToken = jest.fn(() => 'test-token-123')
mockGetTimestamp = jest.fn(() => 1635186000000)
// Create People instance
people = new People(mockSharedProperties, mockGetToken, mockGetTimestamp)
// Mock fetch globally if needed for future implementations
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
})
})
afterEach(() => {
jest.restoreAllMocks()
})
test('constructor initializes with empty properties and null user_id', () => {
expect(people.ownProperties).toEqual({})
expect(people.user_id).toBeNull()
})
test('identify sets user_id correctly', () => {
people.identify('test-user-123')
expect(people.user_id).toBe('test-user-123')
// Note: We're not testing the fetch endpoint as it's marked as TODO in the code
})
test('deleteUser resets user_id and properties', () => {
// Set up initial state
people.user_id = 'test-user-123'
people.ownProperties = {
name: 'Test User',
email: 'test@example.com',
}
// Call deleteUser
people.deleteUser()
// Check results
expect(people.user_id).toBeNull()
expect(people.ownProperties).toEqual({})
})
test('setProperties adds properties correctly', () => {
people.setProperties({
name: 'Test User',
email: 'test@example.com',
age: 30,
})
expect(people.ownProperties).toEqual({
name: 'Test User',
email: 'test@example.com',
age: 30,
})
})
test('setProperties validates properties are objects', () => {
expect(() => {
people.setProperties('not an object')
}).toThrow('Properties must be an object')
})
test('setProperties ignores reserved properties', () => {
people.setProperties({
name: 'Test User',
distinct_id: 'should-be-ignored',
event_name: 'also-ignored',
properties: 'ignored-too',
})
expect(people.ownProperties).toEqual({
name: 'Test User',
})
// Reserved properties should not be present
expect(people.ownProperties.distinct_id).toBeUndefined()
expect(people.ownProperties.event_name).toBeUndefined()
expect(people.ownProperties.properties).toBeUndefined()
})
test('setPropertiesOnce only sets properties that do not exist', () => {
// Set initial property
people.ownProperties = {
name: 'Initial Name',
}
// Try to set name again and add new properties
people.setPropertiesOnce({
name: 'New Name',
email: 'test@example.com',
age: 30,
})
expect(people.ownProperties).toEqual({
name: 'Initial Name', // Should not change
email: 'test@example.com', // Should be added
age: 30, // Should be added
})
})
test('setPropertiesOnce validates properties are objects', () => {
expect(() => {
people.setPropertiesOnce('not an object')
}).toThrow('Properties must be an object')
})
test('appendValues adds value to existing property turning it into an array', () => {
// Set initial string property
people.ownProperties = {
tags: 'tag1',
}
// Append a value
people.appendValues('tags', 'tag2')
expect(people.ownProperties.tags).toEqual(['tag1', 'tag2'])
})
test('appendValues adds value to existing array property', () => {
// Set initial array property
people.ownProperties = {
tags: ['tag1', 'tag2'],
}
// Append a value
people.appendValues('tags', 'tag3')
expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3'])
})
test('appendValues does nothing if property does not exist', () => {
people.ownProperties = {}
people.appendValues('nonexistent', 'value')
expect(people.ownProperties.nonexistent).toBeUndefined()
})
test('appendValues ignores reserved properties', () => {
people.ownProperties = {
distinct_id: 'reserved',
}
people.appendValues('distinct_id', 'new-value')
expect(people.ownProperties.distinct_id).toBe('reserved')
})
test('appendUniqueValues adds unique value to array property', () => {
// Set initial array property
people.ownProperties = {
tags: ['tag1', 'tag2'],
}
// Append a unique value
people.appendUniqueValues('tags', 'tag3')
expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3'])
// Try to append a duplicate value
people.appendUniqueValues('tags', 'tag2')
// Array should remain unchanged
expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3'])
})
test('appendUniqueValues adds unique value to string property', () => {
// Set initial string property
people.ownProperties = {
tag: 'tag1',
}
// Append a different value
people.appendUniqueValues('tag', 'tag2')
expect(people.ownProperties.tag).toEqual(['tag1', 'tag2'])
// Try to append the same value
people.appendUniqueValues('tag', 'tag1')
// Array should remain unchanged
expect(people.ownProperties.tag).toEqual(['tag1', 'tag2'])
})
test('appendUniqueValues does nothing if property does not exist', () => {
people.ownProperties = {}
people.appendUniqueValues('nonexistent', 'value')
expect(people.ownProperties.nonexistent).toBeUndefined()
})
test('increment adds value to existing numerical property', () => {
// Set initial numerical property
people.ownProperties = {
count: 10,
}
// Increment it
people.increment('count', 5)
expect(people.ownProperties.count).toBe(15)
// Decrement it
people.increment('count', -3)
expect(people.ownProperties.count).toBe(12)
})
test('increment does nothing for non-numerical properties', () => {
people.ownProperties = {
name: 'Test',
arrayProp: [1, 2, 3],
}
people.increment('name', 5)
people.increment('arrayProp', 5)
expect(people.ownProperties.name).toBe('Test')
expect(people.ownProperties.arrayProp).toEqual([1, 2, 3])
})
test('increment ignores reserved properties', () => {
people.ownProperties = {
distinct_id: 10,
}
people.increment('distinct_id', 5)
expect(people.ownProperties.distinct_id).toBe(10)
})
})

View file

@ -0,0 +1,186 @@
// @ts-nocheck
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'
import SharedProperties from '../sharedProperties.js'
import * as utils from '../utils.js'
// Mock the utils module
jest.mock('../utils.js', () => ({
uaParse: jest.fn(),
isObject: jest.requireActual('../utils.js').isObject,
getUTCOffsetString: () => 'UTC+01:00'
}))
describe('SharedProperties', () => {
let mockApp
let mockWindow
let sessionStorage
let localStorage
const refKey = '$__initial_ref__$'
const distinctIdKey = '$__distinct_device_id__$'
beforeEach(() => {
// Create mock storage implementations
sessionStorage = {
[refKey]: 'https://example.com',
}
localStorage = {}
// Mock app with storage methods
mockApp = {
sessionStorage: {
getItem: jest.fn((key) => sessionStorage[key] || null),
setItem: jest.fn((key, value) => {
sessionStorage[key] = value
}),
},
localStorage: {
getItem: jest.fn((key) => localStorage[key] || null),
setItem: jest.fn((key, value) => {
localStorage[key] = value
}),
},
}
// Mock window
mockWindow = {
location: {
search: '?utm_source=test_source&utm_medium=test_medium&utm_campaign=test_campaign',
},
}
// Mock document
global.document = {
referrer: 'https://example.com',
}
// Mock window globally to make it available to the constructor
global.window = mockWindow
// Setup mock data for uaParse
utils.uaParse.mockReturnValue({
width: 1920,
height: 1080,
browser: 'Chrome',
browserVersion: '91.0.4472.124',
browserMajorVersion: 91,
os: 'Windows',
osVersion: '10',
mobile: false,
})
// Reset Math.random to ensure predictable device IDs for testing
jest
.spyOn(Math, 'random')
.mockReturnValueOnce(0.1) // First call
.mockReturnValueOnce(0.2) // Second call
.mockReturnValueOnce(0.3) // Third call
})
afterEach(() => {
jest.restoreAllMocks()
delete global.document
delete global.window
})
test('constructs with correct properties', () => {
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(utils.uaParse).toHaveBeenCalledWith(window)
expect(properties.os).toBe('Windows')
expect(properties.osVersion).toBe('10')
expect(properties.browser).toBe('Chrome')
expect(properties.browserVersion).toBe('91.0.4472.124 (91)')
expect(properties.device).toBe('Desktop')
expect(properties.screenHeight).toBe(1080)
expect(properties.screenWidth).toBe(1920)
})
test('detects UTM parameters correctly', () => {
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(properties.utmSource).toBe('test_source')
expect(properties.utmMedium).toBe('test_medium')
expect(properties.utmCampaign).toBe('test_campaign')
})
test('handles missing UTM parameters', () => {
mockWindow.location.search = ''
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(properties.utmSource).toBeNull()
expect(properties.utmMedium).toBeNull()
expect(properties.utmCampaign).toBeNull()
})
test('generates new device ID if none exists', () => {
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(mockApp.localStorage.getItem).toHaveBeenCalledWith(distinctIdKey)
expect(mockApp.localStorage.setItem).toHaveBeenCalled()
// (a-z0-9)\-(a-z0-9)\-(a-z0-9)
expect(properties.deviceId).toMatch(/^[a-z0-9]{6,12}-[a-z0-9]{6,12}-[a-z0-9]{6,12}$/)
})
test('uses existing device ID if available', () => {
localStorage[distinctIdKey] = 'existing-device-id'
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(mockApp.localStorage.getItem).toHaveBeenCalledWith(distinctIdKey)
expect(mockApp.localStorage.setItem).not.toHaveBeenCalled()
expect(properties.deviceId).toBe('existing-device-id')
})
test('gets referrer from session storage if available', () => {
sessionStorage[refKey] = 'https://stored-referrer.com'
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(mockApp.sessionStorage.getItem).toHaveBeenCalledWith(refKey)
expect(mockApp.sessionStorage.setItem).not.toHaveBeenCalled()
expect(properties.initialReferrer).toBe('https://stored-referrer.com')
})
test('returns all properties with correct prefixes', () => {
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
const allProps = properties.all
expect(allProps).toMatchObject({
$os: 'Windows',
$os_version: '10',
$browser: 'Chrome',
$browser_version: '91.0.4472.124 (91)',
$device: 'Desktop',
$screen_height: 1080,
$screen_width: 1920,
$initial_referrer: expect.stringMatching(/^https:\/\/example\.com$/),
$utm_source: 'test_source',
$utm_medium: 'test_medium',
$utm_campaign: 'test_campaign',
$distinct_id: expect.stringMatching(/^[a-z0-9]{6,12}-[a-z0-9]{6,12}-[a-z0-9]{6,12}$/),
$search_engine: null,
$user_id: null,
$device_id: expect.stringMatching(/^[a-z0-9]{6,12}-[a-z0-9]{6,12}-[a-z0-9]{6,12}$/),
})
})
test('handles mobile device detection', () => {
utils.uaParse.mockReturnValue({
width: 375,
height: 812,
browser: 'Safari',
browserVersion: '14.1.1',
browserMajorVersion: 14,
os: 'iOS',
osVersion: '14.6',
mobile: true,
})
const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage)
expect(properties.device).toBe('Mobile')
expect(properties.os).toBe('iOS')
expect(properties.osVersion).toBe('14.6')
expect(properties.browser).toBe('Safari')
expect(properties.browserVersion).toBe('14.1.1 (14)')
})
})

View file

@ -0,0 +1,131 @@
// @ts-nocheck
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'
import { uaParse, isObject } from '../utils.js'
describe('isObject', () => {
test('returns true for objects', () => {
expect(isObject({})).toBe(true)
expect(isObject({ a: 1 })).toBe(true)
expect(isObject(new Object())).toBe(true)
})
test('returns false for non-objects', () => {
expect(isObject(null)).toBe(false)
expect(isObject(undefined)).toBe(false)
expect(isObject([])).toBe(false)
expect(isObject('string')).toBe(false)
expect(isObject(123)).toBe(false)
expect(isObject(true)).toBe(false)
expect(isObject(function () {})).toBe(false)
})
})
describe('uaParse', () => {
let originalNavigator
let originalScreen
let originalDocument
let mockWindow
beforeEach(() => {
// Save original objects
originalNavigator = global.navigator
originalScreen = global.screen
originalDocument = global.document
// Create mock screen
global.screen = {
width: 1920,
height: 1080,
}
// Setup mock document
global.document = {
cookie: 'testcookie=1',
}
// Setup mock window with basic navigator
mockWindow = {
navigator: {
appVersion: 'test version',
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
appName: 'Netscape',
cookieEnabled: true,
},
screen: {
width: 1920,
height: 1080,
},
document: global.document,
}
// Set global navigator
global.navigator = mockWindow.navigator
})
afterEach(() => {
// Restore original objects
global.navigator = originalNavigator
global.screen = originalScreen
global.document = originalDocument
})
test('detects Chrome browser correctly', () => {
const result = uaParse(mockWindow)
expect(result.browser).toBe('Chrome')
expect(result.browserMajorVersion).toBe(91)
expect(result.os).toBe('Windows')
})
test('detects screen dimensions correctly', () => {
const result = uaParse(mockWindow)
expect(result.width).toBe(1920)
expect(result.height).toBe(1080)
expect(result.screen).toBe('1920 x 1080')
})
test('detects mobile devices correctly', () => {
mockWindow.navigator.userAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1'
mockWindow.navigator.appVersion = 'iPhone'
const result = uaParse(mockWindow)
expect(result.mobile).toBe(true)
expect(result.os).toBe('iOS')
})
test('detects Firefox browser correctly', () => {
mockWindow.navigator.userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0'
const result = uaParse(mockWindow)
expect(result.browser).toBe('Firefox')
expect(result.browserMajorVersion).toBe(89)
})
test('detects Edge browser correctly', () => {
mockWindow.navigator.userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59'
const result = uaParse(mockWindow)
expect(result.browser).toBe('Microsoft Edge')
})
test('detects cookies correctly', () => {
const result = uaParse(mockWindow)
expect(result.cookies).toBe(true)
mockWindow.navigator.cookieEnabled = false
const result2 = uaParse(mockWindow)
expect(result2.cookies).toBe(false)
})
test('handles undefined screen dimensions', () => {
delete global.screen.width
delete global.screen.height
delete mockWindow.screen.width
delete mockWindow.screen.height
const result = uaParse(mockWindow)
expect(result.width).toBe(0)
expect(result.height).toBe(0)
expect(result.screen).toBe('')
})
})

View file

@ -0,0 +1,250 @@
interface ClientData {
screen: string
width: number
height: number
browser: string
browserVersion: string
browserMajorVersion: number
mobile: boolean
os: string
osVersion: string
cookies: boolean
}
interface ClientOS {
s: string
r: RegExp
}
/**
* Detects client browser, OS, and device information
*/
export function uaParse(sWindow: Window & typeof globalThis): ClientData {
const unknown = '-'
// Screen detection
let width: number = 0
let height: number = 0
let screenSize = ''
if (sWindow.screen.width) {
width = sWindow.screen.width
height = sWindow.screen.height
screenSize = `${width} x ${height}`
}
// Browser detection
const nVer: string = sWindow.navigator.appVersion
const nAgt: string = sWindow.navigator.userAgent
let browser: string = sWindow.navigator.appName
let version: string = String(parseFloat(nVer))
let nameOffset: number
let verOffset: number
let ix: number
// Browser detection logic
if ((verOffset = nAgt.indexOf('YaBrowser')) !== -1) {
browser = 'Yandex'
version = nAgt.substring(verOffset + 10)
} else if ((verOffset = nAgt.indexOf('SamsungBrowser')) !== -1) {
browser = 'Samsung'
version = nAgt.substring(verOffset + 15)
} else if ((verOffset = nAgt.indexOf('UCBrowser')) !== -1) {
browser = 'UC Browser'
version = nAgt.substring(verOffset + 10)
} else if ((verOffset = nAgt.indexOf('OPR')) !== -1) {
browser = 'Opera'
version = nAgt.substring(verOffset + 4)
} else if ((verOffset = nAgt.indexOf('Opera')) !== -1) {
browser = 'Opera'
version = nAgt.substring(verOffset + 6)
if ((verOffset = nAgt.indexOf('Version')) !== -1) {
version = nAgt.substring(verOffset + 8)
}
} else if ((verOffset = nAgt.indexOf('Edge')) !== -1) {
browser = 'Microsoft Legacy Edge'
version = nAgt.substring(verOffset + 5)
} else if ((verOffset = nAgt.indexOf('Edg')) !== -1) {
browser = 'Microsoft Edge'
version = nAgt.substring(verOffset + 4)
} else if ((verOffset = nAgt.indexOf('MSIE')) !== -1) {
browser = 'Microsoft Internet Explorer'
version = nAgt.substring(verOffset + 5)
} else if ((verOffset = nAgt.indexOf('Chrome')) !== -1) {
browser = 'Chrome'
version = nAgt.substring(verOffset + 7)
} else if ((verOffset = nAgt.indexOf('Safari')) !== -1) {
browser = 'Safari'
version = nAgt.substring(verOffset + 7)
if ((verOffset = nAgt.indexOf('Version')) !== -1) {
version = nAgt.substring(verOffset + 8)
}
} else if ((verOffset = nAgt.indexOf('Firefox')) !== -1) {
browser = 'Firefox'
version = nAgt.substring(verOffset + 8)
} else if (nAgt.indexOf('Trident/') !== -1) {
browser = 'Microsoft Internet Explorer'
version = nAgt.substring(nAgt.indexOf('rv:') + 3)
} else if ((nameOffset = nAgt.lastIndexOf(' ') + 1) < (verOffset = nAgt.lastIndexOf('/'))) {
browser = nAgt.substring(nameOffset, verOffset)
version = nAgt.substring(verOffset + 1)
if (browser.toLowerCase() === browser.toUpperCase()) {
browser = sWindow.navigator.appName
}
}
// Trim the version string
if ((ix = version.indexOf(';')) !== -1) {
version = version.substring(0, ix)
}
if ((ix = version.indexOf(' ')) !== -1) {
version = version.substring(0, ix)
}
if ((ix = version.indexOf(')')) !== -1) {
version = version.substring(0, ix)
}
let majorVersion: number = parseInt(version, 10)
if (isNaN(majorVersion)) {
version = String(parseFloat(nVer))
majorVersion = parseInt(nVer, 10)
}
// Mobile detection
const mobile: boolean = /Mobile|mini|Fennec|Android|iP(ad|od|hone)/.test(nVer)
// Cookie detection
let cookieEnabled: boolean = sWindow.navigator.cookieEnabled || false
if (typeof navigator.cookieEnabled === 'undefined' && !cookieEnabled) {
sWindow.document.cookie = 'testcookie'
cookieEnabled = sWindow.document.cookie.indexOf('testcookie') !== -1
}
// OS detection
let os: string = unknown
const clientStrings: ClientOS[] = [
{ s: 'Windows 10', r: /(Windows 10.0|Windows NT 10.0)/ },
{ s: 'Windows 8.1', r: /(Windows 8.1|Windows NT 6.3)/ },
{ s: 'Windows 8', r: /(Windows 8|Windows NT 6.2)/ },
{ s: 'Windows 7', r: /(Windows 7|Windows NT 6.1)/ },
{ s: 'Windows Vista', r: /Windows NT 6.0/ },
{ s: 'Windows Server 2003', r: /Windows NT 5.2/ },
{ s: 'Windows XP', r: /(Windows NT 5.1|Windows XP)/ },
{ s: 'Windows 2000', r: /(Windows NT 5.0|Windows 2000)/ },
{ s: 'Windows ME', r: /(Win 9x 4.90|Windows ME)/ },
{ s: 'Windows 98', r: /(Windows 98|Win98)/ },
{ s: 'Windows 95', r: /(Windows 95|Win95|Windows_95)/ },
{ s: 'Windows NT 4.0', r: /(Windows NT 4.0|WinNT4.0|WinNT|Windows NT)/ },
{ s: 'Windows CE', r: /Windows CE/ },
{ s: 'Windows 3.11', r: /Win16/ },
{ s: 'Android', r: /Android/ },
{ s: 'Open BSD', r: /OpenBSD/ },
{ s: 'Sun OS', r: /SunOS/ },
{ s: 'Chrome OS', r: /CrOS/ },
{ s: 'Linux', r: /(Linux|X11(?!.*CrOS))/ },
{ s: 'iOS', r: /(iPhone|iPad|iPod)/ },
{ s: 'Mac OS X', r: /Mac OS X/ },
{ s: 'Mac OS', r: /(Mac OS|MacPPC|MacIntel|Mac_PowerPC|Macintosh)/ },
{ s: 'QNX', r: /QNX/ },
{ s: 'UNIX', r: /UNIX/ },
{ s: 'BeOS', r: /BeOS/ },
{ s: 'OS/2', r: /OS\/2/ },
{
s: 'Search Bot',
r: /(nuhk|Googlebot|Yammybot|Openbot|Slurp|MSNBot|Ask Jeeves\/Teoma|ia_archiver)/,
},
]
// Find matching OS
for (const client of clientStrings) {
if (client.r.test(nAgt)) {
os = client.s
break
}
}
// OS Version detection
let osVersion: string = unknown
if (/Windows/.test(os)) {
const matches = /Windows (.*)/.exec(os)
if (matches && matches[1]) {
osVersion = matches[1]
// Handle Windows 10/11 detection with newer API if available
if (osVersion === '10' && 'userAgentData' in sWindow.navigator) {
const nav = navigator as Navigator & {
userAgentData?: {
getHighEntropyValues(values: string[]): Promise<{ platformVersion: string }>
}
}
if (nav.userAgentData) {
nav.userAgentData
.getHighEntropyValues(['platformVersion'])
.then((ua) => {
const version = parseInt(ua.platformVersion.split('.')[0], 10)
osVersion = version < 13 ? '10' : '11'
})
.catch(() => {
// Fallback if high entropy values not available
})
}
}
}
os = 'Windows'
}
// OS version detection for Mac/Android/iOS
switch (os) {
case 'Mac OS':
case 'Mac OS X':
case 'Android': {
const matches =
/(?:Android|Mac OS|Mac OS X|MacPPC|MacIntel|Mac_PowerPC|Macintosh) ([\.\_\d]+)/.exec(nAgt)
osVersion = matches && matches[1] ? matches[1] : unknown
break
}
case 'iOS': {
const matches = /OS (\d+)_(\d+)_?(\d+)?/.exec(nVer)
if (matches && matches[1]) {
osVersion = `${matches[1]}.${matches[2]}.${parseInt(matches[3] || '0', 10)}`
}
break
}
}
// Return client data
return {
screen: screenSize,
width,
height,
browser,
browserVersion: version,
browserMajorVersion: majorVersion,
mobile,
os,
osVersion,
cookies: cookieEnabled,
}
}
export function isObject(item: any): boolean {
const isNull = item === null
return Boolean(item && typeof item === 'object' && !Array.isArray(item) && !isNull)
}
export function getUTCOffsetString() {
const date = new Date()
const offsetMinutes = date.getTimezoneOffset()
const hours = Math.abs(Math.floor(offsetMinutes / 60))
const minutes = Math.abs(offsetMinutes % 60)
const sign = offsetMinutes <= 0 ? '+' : '-'
const hoursStr = hours.toString().padStart(2, '0')
const minutesStr = minutes.toString().padStart(2, '0')
return `UTC${sign}${hoursStr}:${minutesStr}`
}

View file

@ -15,7 +15,9 @@ export class StringDictionary {
getKey = (str: string): [number, boolean] => {
let isNew = false
if (!this.backDict[str]) {
// avoiding potential native object properties
const safeKey = `__${str}`
if (!this.backDict[safeKey]) {
isNew = true
// shaving the first 2 digits of the timestamp (since they are irrelevant for next millennia)
const shavedTs = Date.now() % 10 ** (13 - 2)
@ -26,10 +28,10 @@ export class StringDictionary {
} else {
this.lastSuffix = 1
}
this.backDict[str] = id
this.backDict[safeKey] = id
this.lastTs = shavedTs
}
return [this.backDict[str], isNew]
return [this.backDict[safeKey], isNew]
}
}

View file

@ -75,7 +75,6 @@ describe('Singleton Testing', () => {
singleton.configure(options);
methods.forEach(method => {
console.log(method);
expect(singleton[method]).toBeDefined();
});
})