diff options
-rw-r--r-- | packages/instant/src/components/zero_ex_instant_provider.tsx | 16 | ||||
-rw-r--r-- | packages/instant/src/constants.ts | 4 | ||||
-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 | 115 | ||||
-rw-r--r-- | packages/instant/src/util/provider_state_factory.ts | 2 | ||||
-rw-r--r-- | packages/instant/webpack.config.js | 21 |
9 files changed, 284 insertions, 5 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..52454148f 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; + disableAnalyticsTracking: boolean; } export class ZeroExInstantProvider extends React.Component<ZeroExInstantProviderProps> { @@ -121,6 +123,20 @@ export class ZeroExInstantProvider extends React.Component<ZeroExInstantProvider gasPriceEstimator.getGasInfoAsync(); // tslint:disable-next-line:no-floating-promises this._flashErrorIfWrongNetwork(); + + // Analytics + if (this.props.disableAnalyticsTracking) { + disableAnalytics(); + } + 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 5bd2349b3..26d5ff436 100644 --- a/packages/instant/src/constants.ts +++ b/packages/instant/src/constants.ts @@ -16,6 +16,9 @@ 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 ANALYTICS_ENABLED = process.env.NODE_ENV === 'production' || process.env.ENABLE_HEAP; +export const HEAP_ANALYTICS_DEVELOPMENT_APP_ID = '507265531'; +export const HEAP_ANALYTICS_PRODUCTION_APP_ID = '2323640988'; 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; @@ -47,4 +50,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/redux/analytics_middleware.ts b/packages/instant/src/redux/analytics_middleware.ts new file mode 100644 index 000000000..a86cf1b83 --- /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 justTurnedReady = curAccount.state === AccountState.Ready && prevAccount.state !== AccountState.Ready; + if (justTurnedReady) { + 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 67f21a396..31162e471 100644 --- a/packages/instant/src/types.ts +++ b/packages/instant/src/types.ts @@ -164,4 +164,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..ce40d08e7 --- /dev/null +++ b/packages/instant/src/util/analytics.ts @@ -0,0 +1,64 @@ +import { ObjectMap } from '@0x/types'; + +import { heapUtil } from './heap'; + +let disabled = false; +export const disableAnalytics = () => { + disabled = true; +}; +export const evaluateIfEnabled = (fnCall: () => void) => { + if (disabled) { + 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..1871c4abc --- /dev/null +++ b/packages/instant/src/util/heap.ts @@ -0,0 +1,115 @@ +import { ObjectMap } from '@0x/types'; +import { logUtils } from '@0x/utils'; + +import { ANALYTICS_ENABLED, HEAP_ANALYTICS_DEVELOPMENT_APP_ID, HEAP_ANALYTICS_PRODUCTION_APP_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 getHeapAppId = (): string => { + if (process.env.NODE_ENV === 'production') { + return HEAP_ANALYTICS_PRODUCTION_APP_ID; + } + return HEAP_ANALYTICS_DEVELOPMENT_APP_ID; +}; + +const setupZeroExInstantHeap = () => { + 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(getHeapAppId()); + /* 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 (!ANALYTICS_ENABLED) { + return; + } + + const curHeap = heapUtil.getHeap(); + if (curHeap) { + try { + if (curHeap.appid !== getHeapAppId()) { + // 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 239950866..ce4f1672e 100644 --- a/packages/instant/webpack.config.js +++ b/packages/instant/webpack.config.js @@ -1,7 +1,16 @@ -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 GIT_SHA = childProcess + .execSync('git rev-parse HEAD') + .toString() + .trim(); + const outputPath = process.env.WEBPACK_OUTPUT_PATH || 'umd'; const config = { entry: { @@ -13,6 +22,16 @@ const config = { library: 'zeroExInstant', libraryTarget: 'umd', }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: JSON.stringify(process.env.NODE_ENV), + GIT_SHA: JSON.stringify(GIT_SHA), + ENABLE_HEAP: JSON.stringify(process.env.ENABLE_HEAP), + NPM_PACKAGE_VERSION: JSON.stringify(process.env.npm_package_version), + }, + }), + ], devtool: 'source-map', resolve: { extensions: ['.js', '.json', '.ts', '.tsx'], |