Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: core package for seamless integration with amplitude analytics #15

Merged
merged 30 commits into from
Jan 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c181d36
initial impl; need update logic, tests, deployment
bgiori Nov 18, 2021
0de477c
fix export, add test files
bgiori Nov 18, 2021
cc25e5a
add basic test case
bgiori Nov 18, 2021
1c8b8d9
fix lint, add update user properties logic; TODO Tests
bgiori Nov 19, 2021
ace689b
complete tests
bgiori Nov 22, 2021
ecda2d4
add amplitude-js as dev dependencies and clean up redeclared any
bgiori Nov 22, 2021
f3fbbfc
core instances be stored in global var
bgiori Nov 23, 2021
8e8638d
commented code for consumer/producer
bgiori Nov 29, 2021
0f40f3c
remove provider consumer
bgiori Nov 29, 2021
fda7083
rename factory method
bgiori Nov 29, 2021
2b7ba13
update test
bgiori Nov 29, 2021
b0c2a90
use static init
bgiori Nov 30, 2021
363aa5f
fix build
bgiori Nov 30, 2021
9bb2b92
update rollup
bgiori Nov 30, 2021
a90cd88
remove unecessary dev deps
bgiori Dec 1, 2021
ff60d84
copy user properties when editing identity to call listener
bgiori Dec 8, 2021
b3c0897
0.0.2
bgiori Dec 8, 2021
91f6987
0.0.3
bgiori Dec 8, 2021
ca7f2ff
fix core tests
bgiori Dec 13, 2021
5f04d68
add max queue size
bgiori Dec 13, 2021
c522ed2
add app context provider to core
bgiori Dec 17, 2021
cf214a1
1.0.0-alpha.0
bgiori Dec 20, 2021
c9ffcc7
remove non-idempotent id operations
bgiori Jan 10, 2022
ba74d7f
v1.0.0
bgiori Jan 13, 2022
ed9f230
remove unecessary dev dependencies
bgiori Jan 13, 2022
5a394b3
undo browser changes
bgiori Jan 13, 2022
9a4becc
actually undo last undo
bgiori Jan 13, 2022
227c262
Merge branch 'main' into core
bgiori Jan 25, 2022
3218c5d
fix lint
bgiori Jan 25, 2022
7591915
add ua parser js as a dependency
bgiori Jan 25, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
dist/
*.md
*.test.ts
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.1",
"description": "Javascript Client SDK for Amplitude Experiment",
"scripts": {
"build": "yarn workspace @amplitude/experiment-js-client build",
"build": "yarn workspace @amplitude/amplitude-core build && yarn workspace @amplitude/experiment-js-client build",
"lint": "lerna run lint",
"test": "jest",
"start": "yarn workspace browser-demo start"
Expand Down
Empty file added packages/core/CHANGELOG.md
Empty file.
21 changes: 21 additions & 0 deletions packages/core/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { pathsToModuleNameMapper } = require('ts-jest/utils');

const package = require('./package');
const { compilerOptions } = require('./tsconfig.test.json');

module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
displayName: package.name,
name: package.name,
rootDir: '.',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.test.json',
},
},
};
38 changes: 38 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@amplitude/amplitude-core",
"version": "1.0.0",
"description": "Core package for Amplitide SDKs",
"main": "dist/core.umd.js",
"types": "dist/types/src/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rm -rf dist && rollup -c",
"docs": "typedoc",
"lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore",
"test": "jest",
"version": "yarn docs && git add ../../docs",
"prepublish": "yarn build"
},
"repository": {
"type": "git",
"url": "https://github.com/amplitude/experiment-js-client.git",
"directory": "packages/core"
},
"author": "Amplitude",
"license": "MIT",
"private": false,
"bugs": {
"url": "https://github.com/amplitude/experiment-js-client/issues"
},
"homepage": "https://github.com/amplitude/experiment-js-client#readme",
"dependencies": {
"@amplitude/ua-parser-js": "0.7.26"
},
"devDependencies": {
"@types/amplitude-js": "^8.0.2",
"amplitude-js": "^8.12.0"
},
"gitHead": "0a910f04a64dafcf37b68be45ed7dca58fdd6acf"
}
43 changes: 43 additions & 0 deletions packages/core/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';

import tsConfig from './tsconfig.json';

const browserConfig = {
input: 'src/index.ts',
output: {
dir: 'dist',
entryFileNames: 'core.umd.js',
exports: 'named',
format: 'umd',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious is esm also supported?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to add support for this later. High priority ATM but not blocking this release.

name: 'Experiment',
},
treeshake: {
moduleSideEffects: 'no-external',
},
external: [],
plugins: [
replace({ BUILD_BROWSER: true }),
resolve(),
json(),
commonjs(),
typescript({
declaration: true,
declarationDir: 'dist/types',
include: tsConfig.include,
rootDir: '.',
}),
babel({
babelHelpers: 'bundled',
exclude: ['node_modules/**'],
}),
],
};

const configs = [browserConfig];

export default configs;
19 changes: 19 additions & 0 deletions packages/core/src/amplitudeCore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AnalyticsConnectorImpl } from './analyticsConnector';
import { ApplicationContextProviderImpl } from './applicationContextProvider';
import { IdentityStoreImpl } from './identityStore';
import { safeGlobal } from './util/global';

safeGlobal['amplitudeCoreInstances'] = {};

export class AmplitudeCore {
public readonly identityStore = new IdentityStoreImpl();
public readonly analyticsConnector = new AnalyticsConnectorImpl();
public readonly applicationContextProvider = new ApplicationContextProviderImpl();

static getInstance(instanceName: string): AmplitudeCore {
if (!safeGlobal['amplitudeCoreInstances'][instanceName]) {
safeGlobal['amplitudeCoreInstances'][instanceName] = new AmplitudeCore();
}
return safeGlobal['amplitudeCoreInstances'][instanceName];
}
}
37 changes: 37 additions & 0 deletions packages/core/src/analyticsConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type AnalyticsEvent = {
eventType: string;
eventProperties?: Record<string, unknown>;
userProperties?: Record<string, unknown>;
};

export type AnalyticsEventReceiver = (event: AnalyticsEvent) => void;

export interface AnalyticsConnector {
logEvent(event: AnalyticsEvent): void;
setEventReceiver(listener: AnalyticsEventReceiver): void;
}

export class AnalyticsConnectorImpl implements AnalyticsConnector {
private receiver: AnalyticsEventReceiver;
private queue: AnalyticsEvent[] = [];

logEvent(event: AnalyticsEvent): void {
if (!this.receiver) {
if (this.queue.length < 512) {
this.queue.push(event);
}
} else {
this.receiver(event);
}
}

setEventReceiver(receiver: AnalyticsEventReceiver): void {
this.receiver = receiver;
if (this.queue.length > 0) {
this.queue.forEach((event) => {
receiver(event);
});
this.queue = [];
}
}
}
48 changes: 48 additions & 0 deletions packages/core/src/applicationContextProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { UAParser } from '@amplitude/ua-parser-js';

export type ApplicationContext = {
versionName?: string;
language?: string;
platform?: string;
os?: string;
deviceModel?: string;
};

export interface ApplicationContextProvider {
versionName: string;
getApplicationContext(): ApplicationContext;
}

export class ApplicationContextProviderImpl
implements ApplicationContextProvider {
private readonly ua = new UAParser(navigator.userAgent).getResult();
public versionName: string;
getApplicationContext(): ApplicationContext {
return {
versionName: this.versionName,
language: getLanguage(),
platform: 'Web',
os: getOs(this.ua),
deviceModel: getDeviceModel(this.ua),
};
}
}

const getOs = (ua: UAParser): string => {
return [ua.browser?.name, ua.browser?.major]
.filter((e) => e !== null && e !== undefined)
.join(' ');
};

const getDeviceModel = (ua: UAParser): string => {
return ua.os?.name;
};

const getLanguage = (): string => {
return (
(typeof navigator !== 'undefined' &&
((navigator.languages && navigator.languages[0]) ||
navigator.language)) ||
''
);
};
161 changes: 161 additions & 0 deletions packages/core/src/identityStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
const ID_OP_SET = '$set';
const ID_OP_UNSET = '$unset';
const ID_OP_CLEAR_ALL = '$clearAll';

export type Identity = {
userId?: string;
deviceId?: string;
userProperties?: Record<string, unknown>;
};

export type IdentityListener = (identity: Identity) => void;

export interface IdentityStore {
editIdentity(): IdentityEditor;
getIdentity(): Identity;
setIdentity(identity: Identity): void;
addIdentityListener(listener: IdentityListener): void;
removeIdentityListener(listener: IdentityListener): void;
}

export interface IdentityEditor {
setUserId(userId: string): IdentityEditor;
setDeviceId(deviceId: string): IdentityEditor;
setUserProperties(userProperties: Record<string, unknown>): IdentityEditor;
updateUserProperties(
actions: Record<string, Record<string, unknown>>,
): IdentityEditor;
commit(): void;
}

export class IdentityStoreImpl implements IdentityStore {
private identity: Identity = { userProperties: {} };
private listeners = new Set<IdentityListener>();

editIdentity(): IdentityEditor {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: IdentityStore = this;
const actingUserProperties = { ...this.identity.userProperties };
const actingIdentity: Identity = {
...this.identity,
userProperties: actingUserProperties,
};
return {
setUserId: function (userId: string): IdentityEditor {
actingIdentity.userId = userId;
return this;
},

setDeviceId: function (deviceId: string): IdentityEditor {
actingIdentity.deviceId = deviceId;
return this;
},

setUserProperties: function (
userProperties: Record<string, unknown>,
): IdentityEditor {
actingIdentity.userProperties = userProperties;
return this;
},

updateUserProperties: function (
actions: Record<string, Record<string, unknown>>,
): IdentityEditor {
let actingProperties = actingIdentity.userProperties || {};
for (const [action, properties] of Object.entries(actions)) {
switch (action) {
case ID_OP_SET:
for (const [key, value] of Object.entries(properties)) {
actingProperties[key] = value;
}
break;
case ID_OP_UNSET:
for (const key of Object.keys(properties)) {
delete actingProperties[key];
}
break;
case ID_OP_CLEAR_ALL:
actingProperties = {};
break;
}
}
actingIdentity.userProperties = actingProperties;
return this;
},

commit: function (): void {
self.setIdentity(actingIdentity);
return this;
},
};
}

getIdentity(): Identity {
return { ...this.identity };
}

setIdentity(identity: Identity): void {
const originalIdentity = { ...this.identity };
this.identity = { ...identity };
if (!isEqual(originalIdentity, this.identity)) {
this.listeners.forEach((listener) => {
listener(identity);
});
}
}

addIdentityListener(listener: IdentityListener): void {
this.listeners.add(listener);
}

removeIdentityListener(listener: IdentityListener): void {
this.listeners.delete(listener);
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isEqual = (obj1: any, obj2: any): boolean => {
const primitive = ['string', 'number', 'boolean', 'undefined'];
const typeA = typeof obj1;
const typeB = typeof obj2;
if (typeA !== typeB) {
return false;
}
if (primitive.includes(typeA)) {
return obj1 === obj2;
}
//if got here - objects
if (obj1.length !== obj2.length) {
return false;
}
//check if arrays
const isArrayA = Array.isArray(obj1);
const isArrayB = Array.isArray(obj2);
if (isArrayA !== isArrayB) {
return false;
}
if (isArrayA && isArrayB) {
//arrays
for (let i = 0; i < obj1.length; i++) {
if (!isEqual(obj1[i], obj2[i])) {
return false;
}
}
} else {
//objects
const sorted1 = Object.keys(obj1).sort();
const sorted2 = Object.keys(obj2).sort();
if (!isEqual(sorted1, sorted2)) {
return false;
}
//compare object values
let result = true;
Object.keys(obj1).forEach((key) => {
if (!isEqual(obj1[key], obj2[key])) {
result = false;
}
});
return result;
}
return true;
};
Loading