diff options
-rw-r--r-- | packages/instant/src/components/zero_ex_instant_provider.tsx | 14 | ||||
-rw-r--r-- | packages/instant/src/constants.ts | 2 | ||||
-rw-r--r-- | packages/instant/src/index.umd.ts | 3 | ||||
-rw-r--r-- | packages/instant/src/redux/analytics_middleware.ts | 59 | ||||
-rw-r--r-- | packages/instant/src/redux/store.ts | 7 | ||||
-rw-r--r-- | packages/instant/src/types.ts | 1 | ||||
-rw-r--r-- | packages/instant/src/util/analytics.ts | 64 | ||||
-rw-r--r-- | packages/instant/src/util/heap.ts | 113 | ||||
-rw-r--r-- | packages/instant/src/util/provider_state_factory.ts | 2 | ||||
-rw-r--r-- | packages/instant/webpack.config.js | 115 | ||||
-rw-r--r-- | packages/monorepo-scripts/src/prepublish_checks.ts | 11 |
11 files changed, 347 insertions, 44 deletions
diff --git a/packages/instant/src/components/zero_ex_instant_provider.tsx b/packages/instant/src/components/zero_ex_instant_provider.tsx index 8be53ee20..9435d8c7c 100644 --- a/packages/instant/src/components/zero_ex_instant_provider.tsx +++ b/packages/instant/src/components/zero_ex_instant_provider.tsx @@ -12,6 +12,7 @@ import { DEFAULT_STATE, DefaultState, State } from '../redux/reducer'; import { store, Store } from '../redux/store'; import { fonts } from '../style/fonts'; import { AccountState, AffiliateInfo, AssetMetaData, Network, OrderSource } from '../types'; +import { analytics, disableAnalytics } from '../util/analytics'; import { assetUtils } from '../util/asset'; import { errorFlasher } from '../util/error_flasher'; import { gasPriceEstimator } from '../util/gas_price_estimator'; @@ -36,6 +37,7 @@ export interface ZeroExInstantProviderOptionalProps { additionalAssetMetaDataMap: ObjectMap<AssetMetaData>; networkId: Network; affiliateInfo: AffiliateInfo; + shouldDisableAnalyticsTracking: boolean; } export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { @@ -121,6 +123,18 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider gasPriceEstimator.getGasInfoAsync(); // tslint:disable-next-line:no-floating-promises this._flashErrorIfWrongNetwork(); + + // Analytics + disableAnalytics(this.props.shouldDisableAnalyticsTracking || false); + analytics.addEventProperties({ + embeddedHost: window.location.host, + embeddedUrl: window.location.href, + networkId: state.network, + providerName: state.providerState.name, + gitSha: process.env.GIT_SHA, + npmVersion: process.env.NPM_PACKAGE_VERSION, + }); + analytics.trackInstantOpened(); } public componentWillUnmount(): void { if (this._accountUpdateHeartbeat) { diff --git a/packages/instant/src/constants.ts b/packages/instant/src/constants.ts index ad2d568fe..be6077ca9 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -16,6 +16,7 @@ export const BUY_QUOTE_UPDATE_INTERVAL_TIME_MS = ONE_SECOND_MS * 15; export const DEFAULT_GAS_PRICE = GWEI_IN_WEI.mul(6); export const DEFAULT_ESTIMATED_TRANSACTION_TIME_MS = ONE_MINUTE_MS * 2; export const ETH_GAS_STATION_API_BASE_URL = 'https://ethgasstation.info'; +export const HEAP_ANALYTICS_ID = process.env.HEAP_ANALYTICS_ID; export const COINBASE_API_BASE_URL = 'https://api.coinbase.com/v2'; export const PROGRESS_STALL_AT_WIDTH = '95%'; export const PROGRESS_FINISH_ANIMATION_TIME_MS = 200; @@ -48,4 +49,5 @@ export const PROVIDER_TYPE_TO_NAME: { [key in ProviderType]: string } = { [ProviderType.Mist]: 'Mist', [ProviderType.CoinbaseWallet]: 'Coinbase Wallet', [ProviderType.Parity]: 'Parity', + [ProviderType.Fallback]: 'Fallback', }; diff --git a/packages/instant/src/index.umd.ts b/packages/instant/src/index.umd.ts index 0274db30c..5010347b3 100644 --- a/packages/instant/src/index.umd.ts +++ b/packages/instant/src/index.umd.ts @@ -35,6 +35,9 @@ export const render = (props: ZeroExInstantOverlayProps, selector: string = DEFA if (!_.isUndefined(props.provider)) { assert.isWeb3Provider('props.provider', props.provider); } + if (!_.isUndefined(props.shouldDisableAnalyticsTracking)) { + assert.isBoolean('props.shouldDisableAnalyticsTracking', props.shouldDisableAnalyticsTracking); + } assert.isString('selector', selector); const appendToIfExists = document.querySelector(selector); assert.assert(!_.isNull(appendToIfExists), `Could not find div with selector: ${selector}`); diff --git a/packages/instant/src/redux/analytics_middleware.ts b/packages/instant/src/redux/analytics_middleware.ts new file mode 100644 index 000000000..f971dbd33 --- /dev/null +++ b/packages/instant/src/redux/analytics_middleware.ts @@ -0,0 +1,59 @@ +import { Web3Wrapper } from '@0x/web3-wrapper'; +import * as _ from 'lodash'; +import { Middleware } from 'redux'; + +import { ETH_DECIMALS } from '../constants'; +import { Account, AccountState } from '../types'; +import { analytics } from '../util/analytics'; + +import { Action, ActionTypes } from './actions'; + +import { State } from './reducer'; + +const shouldTriggerWalletReady = (prevAccount: Account, curAccount: Account): boolean => { + const didJustTurnReady = curAccount.state === AccountState.Ready && prevAccount.state !== AccountState.Ready; + if (didJustTurnReady) { + return true; + } + + if (curAccount.state === AccountState.Ready && prevAccount.state === AccountState.Ready) { + // Account was ready, and is now ready again, but address has changed + return curAccount.address !== prevAccount.address; + } + + return false; +}; + +export const analyticsMiddleware: Middleware = store => next => middlewareAction => { + const prevState = store.getState() as State; + const prevAccount = prevState.providerState.account; + + const nextAction = next(middlewareAction) as Action; + + const curState = store.getState() as State; + const curAccount = curState.providerState.account; + + switch (nextAction.type) { + case ActionTypes.SET_ACCOUNT_STATE_READY: + if (curAccount.state === AccountState.Ready && shouldTriggerWalletReady(prevAccount, curAccount)) { + const ethAddress = curAccount.address; + analytics.addUserProperties({ ethAddress }); + analytics.trackWalletReady(); + } + break; + case ActionTypes.UPDATE_ACCOUNT_ETH_BALANCE: + if ( + curAccount.state === AccountState.Ready && + curAccount.ethBalanceInWei && + !_.isEqual(curAccount, prevAccount) + ) { + const ethBalanceInUnitAmount = Web3Wrapper.toUnitAmount( + curAccount.ethBalanceInWei, + ETH_DECIMALS, + ).toString(); + analytics.addUserProperties({ ethBalanceInUnitAmount }); + } + } + + return nextAction; +}; diff --git a/packages/instant/src/redux/store.ts b/packages/instant/src/redux/store.ts index 20710765d..11bba3876 100644 --- a/packages/instant/src/redux/store.ts +++ b/packages/instant/src/redux/store.ts @@ -1,7 +1,8 @@ import * as _ from 'lodash'; -import { createStore, Store as ReduxStore } from 'redux'; -import { devToolsEnhancer } from 'redux-devtools-extension/developmentOnly'; +import { applyMiddleware, createStore, Store as ReduxStore } from 'redux'; +import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; +import { analyticsMiddleware } from './analytics_middleware'; import { createReducer, State } from './reducer'; export type Store = ReduxStore<State>; @@ -9,6 +10,6 @@ export type Store = ReduxStore<State>; export const store = { create: (initialState: State): Store => { const reducer = createReducer(initialState); - return createStore(reducer, initialState, devToolsEnhancer({})); + return createStore(reducer, initialState, composeWithDevTools(applyMiddleware(analyticsMiddleware))); }, }; diff --git a/packages/instant/src/types.ts b/packages/instant/src/types.ts index 2d4a8a850..999d50fed 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -165,4 +165,5 @@ export enum ProviderType { Mist = 'MIST', CoinbaseWallet = 'COINBASE_WALLET', Cipher = 'CIPHER', + Fallback = 'FALLBACK', } diff --git a/packages/instant/src/util/analytics.ts b/packages/instant/src/util/analytics.ts new file mode 100644 index 000000000..2ffaac1dd --- /dev/null +++ b/packages/instant/src/util/analytics.ts @@ -0,0 +1,64 @@ +import { ObjectMap } from '@0x/types'; + +import { heapUtil } from './heap'; + +let isDisabled = false; +export const disableAnalytics = (shouldDisableAnalytics: boolean) => { + isDisabled = shouldDisableAnalytics; +}; +export const evaluateIfEnabled = (fnCall: () => void) => { + if (isDisabled) { + return; + } + fnCall(); +}; + +enum EventNames { + INSTANT_OPENED = 'Instant - Opened', + WALLET_READY = 'Wallet - Ready', +} +const track = (eventName: EventNames, eventData: ObjectMap<string | number> = {}): void => { + evaluateIfEnabled(() => { + heapUtil.evaluateHeapCall(heap => heap.track(eventName, eventData)); + }); +}; +function trackingEventFnWithoutPayload(eventName: EventNames): () => void { + return () => { + track(eventName); + }; +} +// tslint:disable-next-line:no-unused-variable +function trackingEventFnWithPayload<T extends ObjectMap<string | number>>( + eventName: EventNames, +): (eventDataProperties: T) => void { + return (eventDataProperties: T) => { + track(eventName, eventDataProperties); + }; +} + +export interface AnalyticsUserOptions { + ethAddress?: string; + ethBalanceInUnitAmount?: string; +} +export interface AnalyticsEventOptions { + embeddedHost?: string; + embeddedUrl?: string; + networkId?: number; + providerName?: string; + gitSha?: string; + npmVersion?: string; +} +export const analytics = { + addUserProperties: (properties: AnalyticsUserOptions): void => { + evaluateIfEnabled(() => { + heapUtil.evaluateHeapCall(heap => heap.addUserProperties(properties)); + }); + }, + addEventProperties: (properties: AnalyticsEventOptions): void => { + evaluateIfEnabled(() => { + heapUtil.evaluateHeapCall(heap => heap.addEventProperties(properties)); + }); + }, + trackWalletReady: trackingEventFnWithoutPayload(EventNames.WALLET_READY), + trackInstantOpened: trackingEventFnWithoutPayload(EventNames.INSTANT_OPENED), +}; diff --git a/packages/instant/src/util/heap.ts b/packages/instant/src/util/heap.ts new file mode 100644 index 000000000..78ec3b3cc --- /dev/null +++ b/packages/instant/src/util/heap.ts @@ -0,0 +1,113 @@ +import { ObjectMap } from '@0x/types'; +import { logUtils } from '@0x/utils'; +import * as _ from 'lodash'; + +import { HEAP_ANALYTICS_ID } from '../constants'; + +import { AnalyticsEventOptions, AnalyticsUserOptions } from './analytics'; + +export interface HeapAnalytics { + loaded: boolean; + appid: string; + identify(id: string, idType: string): void; + track(eventName: string, eventProperties?: ObjectMap<string | number>): void; + resetIdentity(): void; + addUserProperties(properties: AnalyticsUserOptions): void; + addEventProperties(properties: AnalyticsEventOptions): void; + removeEventProperty(property: string): void; + clearEventProperties(): void; +} +interface ModifiedWindow { + heap?: HeapAnalytics; + zeroExInstantLoadedHeap?: boolean; +} +const getWindow = (): ModifiedWindow => { + return window as ModifiedWindow; +}; + +const setupZeroExInstantHeap = () => { + if (_.isUndefined(HEAP_ANALYTICS_ID)) { + return; + } + + const curWindow = getWindow(); + // Set property to specify that this is zeroEx's heap + curWindow.zeroExInstantLoadedHeap = true; + + // Typescript-compatible version of https://docs.heapanalytics.com/docs/installation + /* tslint:disable */ + ((window as any).heap = (window as any).heap || []), + ((window as any).heap.load = function(e: any, t: any) { + ((window as any).heap.appid = e), ((window as any).heap.config = t = t || {}); + var r = t.forceSSL || 'https:' === (document.location as Location).protocol, + a = document.createElement('script'); + (a.type = 'text/javascript'), + (a.async = !0), + (a.src = (r ? 'https:' : 'http:') + '//cdn.heapanalytics.com/js/heap-' + e + '.js'); + var n = document.getElementsByTagName('script')[0]; + (n.parentNode as Node).insertBefore(a, n); + for ( + var o = function(e: any) { + return function() { + (window as any).heap.push([e].concat(Array.prototype.slice.call(arguments, 0))); + }; + }, + p = [ + 'addEventProperties', + 'addUserProperties', + 'clearEventProperties', + 'identify', + 'resetIdentity', + 'removeEventProperty', + 'setEventProperties', + 'track', + 'unsetEventProperty', + ], + c = 0; + c < p.length; + c++ + ) + (window as any).heap[p[c]] = o(p[c]); + }); + (window as any).heap.load(HEAP_ANALYTICS_ID); + /* tslint:enable */ + + return curWindow.heap as HeapAnalytics; +}; + +export const heapUtil = { + getHeap: (): HeapAnalytics | undefined => { + const curWindow = getWindow(); + const hasOtherExistingHeapIntegration = curWindow.heap && !curWindow.zeroExInstantLoadedHeap; + if (hasOtherExistingHeapIntegration) { + return undefined; + } + + const zeroExInstantHeapIntegration = curWindow.zeroExInstantLoadedHeap && curWindow.heap; + if (zeroExInstantHeapIntegration) { + return zeroExInstantHeapIntegration; + } + + return setupZeroExInstantHeap(); + }, + evaluateHeapCall: (heapFunctionCall: (heap: HeapAnalytics) => void): void => { + if (_.isUndefined(HEAP_ANALYTICS_ID)) { + return; + } + + const curHeap = heapUtil.getHeap(); + if (curHeap) { + try { + if (curHeap.appid !== HEAP_ANALYTICS_ID) { + // Integrator has included heap after us and reset the app id + return; + } + heapFunctionCall(curHeap); + } catch (e) { + // We never want analytics to crash our React component + // TODO(sk): error reporter here + logUtils.log('Analytics error', e); + } + } + }, +}; diff --git a/packages/instant/src/util/provider_state_factory.ts b/packages/instant/src/util/provider_state_factory.ts index 452a71460..7c788dff2 100644 --- a/packages/instant/src/util/provider_state_factory.ts +++ b/packages/instant/src/util/provider_state_factory.ts @@ -56,7 +56,7 @@ export const providerStateFactory = { getInitialProviderStateFallback: (orderSource: OrderSource, network: Network): ProviderState => { const provider = providerFactory.getFallbackNoSigningProvider(network); const providerState: ProviderState = { - name: envUtil.getProviderName(provider), + name: 'Fallback', provider, web3Wrapper: new Web3Wrapper(provider), assetBuyer: assetBuyerFactory.getAssetBuyer(provider, orderSource, network), diff --git a/packages/instant/webpack.config.js b/packages/instant/webpack.config.js index fae303712..41276809c 100644 --- a/packages/instant/webpack.config.js +++ b/packages/instant/webpack.config.js @@ -1,46 +1,81 @@ -const path = require('path'); +const childProcess = require('child_process'); const ip = require('ip'); +const path = require('path'); +const webpack = require('webpack'); + // The common js bundle (not this one) is built using tsc. // The umd bundle (this one) has a different entrypoint. -const outputPath = process.env.WEBPACK_OUTPUT_PATH || 'umd'; -const config = { - entry: { - instant: './src/index.umd.ts', - }, - output: { - filename: '[name].js', - path: path.resolve(__dirname, outputPath), - library: 'zeroExInstant', - libraryTarget: 'umd', - }, - devtool: 'source-map', - resolve: { - extensions: ['.js', '.json', '.ts', '.tsx'], - }, - module: { - rules: [ - { - test: /\.(ts|tsx)$/, - loader: 'awesome-typescript-loader', - }, - { - test: /\.svg$/, - loader: 'svg-react-loader', - }, + +const GIT_SHA = childProcess + .execSync('git rev-parse HEAD') + .toString() + .trim(); + +const HEAP_PRODUCTION_ENV_VAR_NAME = 'INSTANT_HEAP_ANALYTICS_ID_PRODUCTION'; +const HEAP_DEVELOPMENT_ENV_VAR_NAME = 'INSTANT_HEAP_ANALYTICS_ID_DEVELOPMENT'; +const getHeapAnalyticsId = modeName => { + if (modeName === 'production') { + return process.env[HEAP_PRODUCTION_ENV_VAR_NAME]; + } + + if (modeName === 'development') { + return process.env[HEAP_DEVELOPMENT_ENV_VAR_NAME]; + } + + return undefined; +}; + +module.exports = (env, argv) => { + const outputPath = process.env.WEBPACK_OUTPUT_PATH || 'umd'; + const config = { + entry: { + instant: './src/index.umd.ts', + }, + output: { + filename: '[name].js', + path: path.resolve(__dirname, outputPath), + library: 'zeroExInstant', + libraryTarget: 'umd', + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + GIT_SHA: JSON.stringify(GIT_SHA), + HEAP_ANALYTICS_ID: getHeapAnalyticsId(argv.mode), + NPM_PACKAGE_VERSION: JSON.stringify(process.env.npm_package_version), + }, + }), ], - }, - devServer: { - contentBase: path.join(__dirname, 'public'), - port: 5000, - host: '0.0.0.0', - after: () => { - if (config.devServer.host === '0.0.0.0') { - console.log( - `webpack-dev-server can be accessed externally at: http://${ip.address()}:${config.devServer.port}`, - ); - } + devtool: 'source-map', + resolve: { + extensions: ['.js', '.json', '.ts', '.tsx'], }, - }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + loader: 'awesome-typescript-loader', + }, + { + test: /\.svg$/, + loader: 'svg-react-loader', + }, + ], + }, + devServer: { + contentBase: path.join(__dirname, 'public'), + port: 5000, + host: '0.0.0.0', + after: () => { + if (config.devServer.host === '0.0.0.0') { + console.log( + `webpack-dev-server can be accessed externally at: http://${ip.address()}:${ + config.devServer.port + }`, + ); + } + }, + }, + }; + return config; }; - -module.exports = config; diff --git a/packages/monorepo-scripts/src/prepublish_checks.ts b/packages/monorepo-scripts/src/prepublish_checks.ts index 5f603ebc7..fc550cf3a 100644 --- a/packages/monorepo-scripts/src/prepublish_checks.ts +++ b/packages/monorepo-scripts/src/prepublish_checks.ts @@ -17,6 +17,7 @@ async function prepublishChecksAsync(): Promise<void> { await checkChangelogFormatAsync(updatedPublicPackages); await checkGitTagsForNextVersionAndDeleteIfExistAsync(updatedPublicPackages); await checkPublishRequiredSetupAsync(); + checkRequiredEnvVariables(); } async function checkGitTagsForNextVersionAndDeleteIfExistAsync(updatedPublicPackages: Package[]): Promise<void> { @@ -183,6 +184,16 @@ async function checkPublishRequiredSetupAsync(): Promise<void> { } } +const checkRequiredEnvVariables = () => { + utils.log('Checking required environment variables...'); + const requiredEnvVars = ['INSTANT_HEAP_ANALYTICS_ID_PRODUCTION']; + requiredEnvVars.forEach(requiredEnvVarName => { + if (_.isUndefined(process.env[requiredEnvVarName])) { + throw new Error(`Must have ${requiredEnvVarName} set`); + } + }); +}; + prepublishChecksAsync().catch(err => { utils.log(err.message); process.exit(1); |