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

chore: add experiment segment plugin #128

Merged
merged 15 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ export class ExperimentClient implements Client {
options,
);
} catch (e) {
console.error(e);
if (this.config.debug) {
console.error(e);
}
}
return this;
}
Expand Down
18 changes: 18 additions & 0 deletions packages/plugin-segment/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { pathsToModuleNameMapper } = require('ts-jest');

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

module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
displayName: package.name,
rootDir: '.',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }],
},
};
41 changes: 41 additions & 0 deletions packages/plugin-segment/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@amplitude/experiment-plugin-segment",
"version": "0.1.0",
"private": true,
"description": "Experiment integration for segment analytics",
"author": "Amplitude",
"homepage": "https://github.com/amplitude/experiment-js-client",
"license": "MIT",
"main": "dist/experiment-plugin-segment.umd.js",
"module": "dist/experiment-plugin-segment.esm.js",
"es2015": "dist/experiment-plugin-segment.es2015.js",
"types": "dist/types/src/index.d.ts",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/amplitude/experiment-js-client.git",
"directory": "packages/plugin-segment"
},
"scripts": {
"build": "rm -rf dist && rollup -c",
"clean": "rimraf node_modules dist",
"lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore",
"test": "jest",
"prepublish": "yarn build"
},
"bugs": {
"url": "https://github.com/amplitude/experiment-js-client/issues"
},
"dependencies": {
"@amplitude/experiment-js-client": "^1.11.0",
"@segment/analytics-next": "^1.73.0"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4"
},
"files": [
"dist"
]
}
112 changes: 112 additions & 0 deletions packages/plugin-segment/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { resolve as pathResolve } from 'path';

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 terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import analyze from 'rollup-plugin-analyzer';

import * as packageJson from './package.json';
import tsConfig from './tsconfig.json';

const getCommonBrowserConfig = (target) => ({
input: 'src/index.ts',
treeshake: {
moduleSideEffects: 'no-external',
},
plugins: [
replace({
preventAssignment: true,
BUILD_BROWSER: true,
}),
resolve(),
json(),
commonjs(),
typescript({
...(target === 'es2015' ? { target: 'es2015' } : {}),
declaration: true,
declarationDir: 'dist/types',
include: tsConfig.include,
rootDir: '.',
}),
babel({
configFile:
target === 'es2015'
? pathResolve(__dirname, '../..', 'babel.es2015.config.js')
: undefined,
babelHelpers: 'bundled',
exclude: ['node_modules/**'],
}),
analyze({
summaryOnly: true,
}),
],
});

const getOutputConfig = (outputOptions) => ({
output: {
dir: 'dist',
name: 'Experiment',
...outputOptions,
},
});

const configs = [
// minified build
{
...getCommonBrowserConfig('es5'),
...getOutputConfig({
entryFileNames: 'experiment-plugin-segment.min.js',
exports: 'named',
format: 'umd',
banner: `/* ${packageJson.name} v${packageJson.version} */`,
}),
plugins: [
...getCommonBrowserConfig('es5').plugins,
terser({
format: {
// Don't remove semver comment
comments:
/@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/,
},
}), // Apply terser plugin for minification
],
external: [],
},

// legacy build for field "main" - ie8, umd, es5 syntax
{
...getCommonBrowserConfig('es5'),
...getOutputConfig({
entryFileNames: 'experiment-plugin-segment.umd.js',
exports: 'named',
format: 'umd',
}),
external: [],
},

// tree shakable build for field "module" - ie8, esm, es5 syntax
{
...getCommonBrowserConfig('es5'),
...getOutputConfig({
entryFileNames: 'experiment-plugin-segment.esm.js',
format: 'esm',
}),
external: [],
},

// modern build for field "es2015" - not ie, esm, es2015 syntax
{
...getCommonBrowserConfig('es2015'),
...getOutputConfig({
entryFileNames: 'experiment-plugin-segment.es2015.js',
format: 'esm',
}),
external: [],
},
];

export default configs;
6 changes: 6 additions & 0 deletions packages/plugin-segment/src/global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const safeGlobal =
typeof globalThis !== 'undefined'
? globalThis
: typeof global !== 'undefined'
? global
: self;
3 changes: 3 additions & 0 deletions packages/plugin-segment/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { segmentIntegrationPlugin } from './plugin';
export { segmentIntegrationPlugin as plugin } from './plugin';
export { SegmentIntegrationPlugin, Options } from './types/plugin';
57 changes: 57 additions & 0 deletions packages/plugin-segment/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type {
ExperimentEvent,
ExperimentUser,
IntegrationPlugin,
} from '@amplitude/experiment-js-client';

import { safeGlobal } from './global';
import { snippetInstance } from './snippet';
import { Options, SegmentIntegrationPlugin } from './types/plugin';

export const segmentIntegrationPlugin: SegmentIntegrationPlugin = (
options: Options = {},
) => {
const getInstance = () => {
return options.instance || snippetInstance(options.instanceKey);
};
getInstance();
const plugin: IntegrationPlugin = {
name: '@amplitude/experiment-plugin-segment',
type: 'integration',
setup(): Promise<void> {
const instance = getInstance();
return new Promise<void>((resolve) => instance.ready(() => resolve()));
},
getUser(): ExperimentUser {
const instance = getInstance();
if (instance.initialized) {
return {
user_id: instance.user().id(),
device_id: instance.user().anonymousId(),
user_properties: instance.user().traits(),
};
}
const get = (key: string) => {
return JSON.parse(safeGlobal.localStorage.getItem(key)) || undefined;
};
return {
user_id: get('ajs_user_id'),
device_id: get('ajs_anonymous_id'),
user_properties: get('ajs_user_traits'),
};
},
track(event: ExperimentEvent): boolean {
const instance = getInstance();
if (!instance.initialized) return false;
instance.track(event.eventType, event.eventProperties);
return true;
},
};
if (options.skipSetup) {
plugin.setup = undefined;
}

return plugin;
};

safeGlobal.experimentIntegration = segmentIntegrationPlugin();
47 changes: 47 additions & 0 deletions packages/plugin-segment/src/snippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { safeGlobal } from './global';

/**
* Copied and modified from https://github.com/segmentio/snippet/blob/master/template/snippet.js
*
* This function will set up proxy stubs for functions used by the segment plugin
*
* @param instanceKey the key for the analytics instance on the global object.
*/
export const snippetInstance = (
instanceKey: string | undefined = undefined,
) => {
// define the key where the global analytics object will be accessible
// customers can safely set this to be something else if need be
const key = instanceKey || 'analytics';

// Create a queue, but don't obliterate an existing one!
const analytics = (safeGlobal[key] = safeGlobal[key] || []);

// If the real analytics.js is already on the page return.
if (analytics.initialize) {
return analytics;
}
const fn = 'ready';
if (analytics[fn]) {
return analytics;
}
const factory = function (fn) {
return function () {
if (safeGlobal[key].initialized) {
// Sometimes users assigned analytics to a variable before analytics is
// done loading, resulting in a stale reference. If so, proxy any calls
// to the 'real' analytics instance.
// eslint-disable-next-line prefer-spread,prefer-rest-params
return safeGlobal[key][fn].apply(safeGlobal[key], arguments);
}
// eslint-disable-next-line prefer-rest-params
const args = Array.prototype.slice.call(arguments);
args.unshift(fn);
analytics.push(args);
return analytics;
};
};
// Use the predefined factory, or our own factory to stub the function.
analytics[fn] = (analytics.factory || factory)(fn);
return analytics;
};
25 changes: 25 additions & 0 deletions packages/plugin-segment/src/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { IntegrationPlugin } from '@amplitude/experiment-js-client';
import { Analytics } from '@segment/analytics-next';

export interface Options {
/**
* An existing segment analytics instance. This instance will be used instead
* of the instance on the window defined by the instanceKey.
*/
instance?: Analytics;
/**
* The key of the field on the window that holds the segment analytics
* instance when the script is loaded via the script loader.
*
* Defaults to "analytics".
*/
instanceKey?: string;
/**
* Skip waiting for the segment SDK to load and be ready.
*/
skipSetup?: boolean;
}

export interface SegmentIntegrationPlugin {
(options?: Options): IntegrationPlugin;
}
Loading
Loading