Compare commits
10 commits
main
...
tracker-sd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53cf91bb16 | ||
|
|
fa2923b83d | ||
|
|
529965486c | ||
|
|
9f51ab85da | ||
|
|
226fc867c0 | ||
|
|
2a1c28cc49 | ||
|
|
4c967d4bc1 | ||
|
|
3fdf799bd7 | ||
|
|
9aca716e6b | ||
|
|
cf9ecdc9a4 |
30 changed files with 1589 additions and 71 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
});
|
||||
//...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
});
|
||||
//...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
});
|
||||
//...
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
});
|
||||
//...
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
});
|
||||
//...
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "16.0.1",
|
||||
"version": "16.0.2",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
tracker/tracker/src/main/modules/analytics/events.ts
Normal file
136
tracker/tracker/src/main/modules/analytics/events.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
35
tracker/tracker/src/main/modules/analytics/index.ts
Normal file
35
tracker/tracker/src/main/modules/analytics/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
130
tracker/tracker/src/main/modules/analytics/people.ts
Normal file
130
tracker/tracker/src/main/modules/analytics/people.ts
Normal 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
|
||||
}
|
||||
}
|
||||
144
tracker/tracker/src/main/modules/analytics/sharedProperties.ts
Normal file
144
tracker/tracker/src/main/modules/analytics/sharedProperties.ts
Normal 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
|
||||
}
|
||||
}
|
||||
201
tracker/tracker/src/main/modules/analytics/tests/events.test.ts
Normal file
201
tracker/tracker/src/main/modules/analytics/tests/events.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
258
tracker/tracker/src/main/modules/analytics/tests/people.test.ts
Normal file
258
tracker/tracker/src/main/modules/analytics/tests/people.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
131
tracker/tracker/src/main/modules/analytics/tests/utils.test.ts
Normal file
131
tracker/tracker/src/main/modules/analytics/tests/utils.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
250
tracker/tracker/src/main/modules/analytics/utils.ts
Normal file
250
tracker/tracker/src/main/modules/analytics/utils.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ describe('Singleton Testing', () => {
|
|||
singleton.configure(options);
|
||||
|
||||
methods.forEach(method => {
|
||||
console.log(method);
|
||||
expect(singleton[method]).toBeDefined();
|
||||
});
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue