├── .eslintignore ├── .gitignore ├── docs ├── README.md ├── api │ ├── README.md │ ├── Core.md │ └── ReactMetrics.md ├── Guides.md └── GettingStarted.md ├── .babelrc ├── scripts ├── teardown.sh ├── setup.sh └── sauce │ ├── sauce_connect_block.sh │ ├── sauce_connect_teardown.sh │ └── sauce_connect_setup.sh ├── examples ├── redux │ ├── basic │ │ ├── ActionTypes.js │ │ ├── index.html │ │ ├── action.js │ │ ├── counter.js │ │ ├── metricsMiddleware.js │ │ ├── metrics.config.js │ │ └── app.js │ └── index.html ├── no-router-lib │ ├── basic │ │ ├── home.js │ │ ├── index.html │ │ ├── page.js │ │ ├── metrics.config.js │ │ └── app.js │ └── index.html ├── react-router │ ├── basic │ │ ├── home.js │ │ ├── index.html │ │ ├── user.js │ │ ├── manual-page-view.js │ │ ├── async-page-view.js │ │ ├── metrics.config.js │ │ └── app.js │ ├── metricsElement │ │ ├── home.js │ │ ├── index.html │ │ ├── metrics.config.js │ │ ├── app.js │ │ └── page.js │ └── index.html ├── global.css ├── index.html ├── webpack.config.js └── vendors │ ├── GoogleAnalytics.js │ ├── GoogleTagManager.js │ └── AdobeTagManager.js ├── src ├── core │ ├── utils │ │ ├── isPromise.js │ │ ├── attr2obj.js │ │ └── extractApis.js │ ├── ActionTypes.js │ ├── createService.js │ └── useTrackBindingPlugin.js ├── react │ ├── findRouteComponent.js │ ├── PropTypes.js │ ├── locationEquals.js │ ├── isLocationValid.js │ ├── getRouteState.js │ ├── exposeMetrics.js │ ├── MetricsElement.js │ └── metrics.js └── index.js ├── .editorconfig ├── test ├── metricsMock.js ├── nodeUtils.js ├── execSteps.js ├── TestService.js ├── ReactMetrics │ ├── getRouteState.spec.js │ ├── locationEquals.spec.js │ ├── exposeMetrics.spec.js │ ├── willTrackPageView.spec.js │ ├── MetricsElement.spec.js │ └── metrics.spec.js ├── metrics.config.js └── core │ ├── attr2obj.spec.js │ ├── extractApis.spec.js │ ├── createService.spec.js │ └── useTrackBindingPlugin.spec.js ├── karma.conf.cloud.js ├── karma.conf.ci.js ├── .eslintrc ├── .travis.yml ├── webpack.config.js ├── LICENSE ├── CONTRIBUTING.md ├── karma.conf.js ├── karma.conf.bs.js ├── package.json ├── karma.conf.sauce.js ├── CHANGELOG.md └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | dist/ 4 | coverage/ 5 | npm-debug.log 6 | .DS_Store 7 | .idea 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## Table of Contents 2 | 3 | * [Getting Started](/docs/GettingStarted.md) 4 | * [API](/docs/api) 5 | * [Guides](/docs/Guides.md) 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015-without-strict", "stage-0", "react"], 3 | "plugins": [ 4 | "transform-decorators-legacy" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /scripts/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | if [ "$USE_CLOUD" = "true" ]; then 6 | ./scripts/sauce/sauce_connect_teardown.sh 7 | fi 8 | -------------------------------------------------------------------------------- /examples/redux/basic/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | INCLEMENT: "INCLEMENT", 3 | DECLEMENT: "DECLEMENT", 4 | ROUTE_CHANGE: "ROUTE_CHANGE" 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/utils/isPromise.js: -------------------------------------------------------------------------------- 1 | // to support Promise-like object. 2 | export default function isPromise(value) { 3 | return value && typeof value.then === "function"; 4 | } 5 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | if [ "$USE_CLOUD" = "true" ]; then 6 | ./scripts/sauce/sauce_connect_setup.sh 7 | ./scripts/sauce/sauce_connect_block.sh 8 | fi 9 | -------------------------------------------------------------------------------- /examples/no-router-lib/basic/home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | class Home extends React.Component { 4 | render() { 5 | return

Home

; 6 | } 7 | } 8 | 9 | export default Home; 10 | -------------------------------------------------------------------------------- /examples/react-router/basic/home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | class Home extends React.Component { 4 | render() { 5 | return

Home

; 6 | } 7 | } 8 | 9 | export default Home; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /examples/react-router/metricsElement/home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | class Home extends React.Component { 4 | render() { 5 | return

Home

; 6 | } 7 | } 8 | 9 | export default Home; 10 | -------------------------------------------------------------------------------- /scripts/sauce/sauce_connect_block.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Wait for Connect to be ready before exiting 4 | printf "Connecting to Sauce." 5 | echo "$BROWSER_PROVIDER_READY_FILE" 6 | while [ ! -f $BROWSER_PROVIDER_READY_FILE ]; do 7 | printf "." 8 | sleep .5 9 | done 10 | echo "Connected" 11 | -------------------------------------------------------------------------------- /src/react/findRouteComponent.js: -------------------------------------------------------------------------------- 1 | import {getMountedInstances} from "./exposeMetrics"; 2 | 3 | export default function findRouteComponent() { 4 | const routes = getMountedInstances(); 5 | const component = routes && routes.length > 0 && routes[routes.length - 1]; 6 | return component; 7 | } 8 | -------------------------------------------------------------------------------- /test/metricsMock.js: -------------------------------------------------------------------------------- 1 | export default { 2 | listen: () => {}, 3 | setRouteState: () => {}, 4 | useTrackBinding: () => {}, 5 | destroy: () => {}, 6 | get enabled() { 7 | return true; 8 | }, 9 | api: { 10 | pageView() {}, 11 | track() {} 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /examples/redux/index.html: -------------------------------------------------------------------------------- 1 | 2 | Redux Example 3 | 4 | 5 |

React Metrics Examples / Redux

6 | 9 | 10 | -------------------------------------------------------------------------------- /src/core/ActionTypes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Type of default actions supported by metrics 3 | * 4 | * @module ActionTypes 5 | * @internal 6 | */ 7 | const ActionTypes = { 8 | PAGE_VIEW: "pageView", // request page view track 9 | TRACK: "track" // request custom link track 10 | }; 11 | 12 | export default ActionTypes; 13 | -------------------------------------------------------------------------------- /examples/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Arial; 3 | font-weight: 200; 4 | } 5 | 6 | h1, h2, h3 { 7 | font-weight: 100; 8 | } 9 | 10 | a { 11 | color: hsl(150, 50%, 50%); 12 | } 13 | 14 | a.active { 15 | color: hsl(40, 50%, 50%); 16 | } 17 | 18 | .breadcrumbs a { 19 | text-decoration: none; 20 | } 21 | -------------------------------------------------------------------------------- /examples/no-router-lib/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Router Example 3 | 4 | 5 |

React Metrics Examples / Manual Routing

6 | 9 | 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createMetrics from "./core/createMetrics"; 2 | import metrics from "./react/metrics"; 3 | import PropTypes from "./react/PropTypes"; 4 | import exposeMetrics from "./react/exposeMetrics"; 5 | import MetricsElement from "./react/MetricsElement"; 6 | 7 | export {createMetrics, metrics, exposeMetrics, MetricsElement, PropTypes}; 8 | -------------------------------------------------------------------------------- /scripts/sauce/sauce_connect_teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | 6 | echo "Shutting down Sauce Connect tunnel" 7 | 8 | killall sc 9 | 10 | while [[ -n `ps -ef | grep "sauce-connect-" | grep -v "grep"` ]]; do 11 | printf "." 12 | sleep .5 13 | done 14 | 15 | echo "" 16 | echo "Sauce Connect tunnel has been shut down" 17 | -------------------------------------------------------------------------------- /src/react/PropTypes.js: -------------------------------------------------------------------------------- 1 | import {shape, object, string} from "prop-types"; 2 | 3 | export const metrics = object; 4 | 5 | export const location = shape({ 6 | pathname: string.isRequired, 7 | search: string.isRequired, 8 | query: object, 9 | state: object 10 | }); 11 | 12 | export default { 13 | metrics, 14 | location 15 | }; 16 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Metrics Examples 3 | 4 | 5 |

React Metrics Examples

6 | 11 | -------------------------------------------------------------------------------- /karma.conf.cloud.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | module.exports = function (config) { 3 | if (process.env.BROWSER_TEST === "bs" && process.env.BROWSER_STACK_USERNAME) { 4 | require("./karma.conf.bs.js")(config); 5 | } else if (process.env.SAUCE_USERNAME) { 6 | require("./karma.conf.sauce.js")(config); 7 | } else { 8 | process.exit(1); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /examples/react-router/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Router Example 3 | 4 | 5 |

React Metrics Examples / React Router

6 | 10 | 11 | -------------------------------------------------------------------------------- /src/react/locationEquals.js: -------------------------------------------------------------------------------- 1 | import deepEqual from "deep-equal"; 2 | export default function locationEquals(a, b) { 3 | if (!a && !b) { 4 | return true; 5 | } 6 | if ((a && !b) || (!a && b)) { 7 | return false; 8 | } 9 | 10 | return ( 11 | a.pathname === b.pathname && 12 | a.search === b.search && 13 | deepEqual(a.state, b.state) 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /examples/redux/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | Redux Example 3 | 4 | 5 |

React Metrics Examples / Redux / Basic Example

6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/react-router/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Router Example 3 | 4 | 5 |

React Metrics Examples / React Router / Basic Example

6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/no-router-lib/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Router Example 3 | 4 | 5 |

React Metrics Examples / Manual Routing / Basic Example

6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/react-router/metricsElement/index.html: -------------------------------------------------------------------------------- 1 | 2 | React Router Example 3 | 4 | 5 |

React Metrics Examples / React Router / MetricsElement Example

6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/redux/basic/action.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from "./ActionTypes"; 2 | 3 | export function inclement(id) { 4 | return { 5 | type: ActionTypes.INCLEMENT, 6 | id 7 | }; 8 | } 9 | 10 | export function declement(id) { 11 | return { 12 | type: ActionTypes.DECLEMENT, 13 | id 14 | }; 15 | } 16 | 17 | export function routeChange(location) { 18 | return { 19 | type: ActionTypes.ROUTE_CHANGE, 20 | location 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /test/nodeUtils.js: -------------------------------------------------------------------------------- 1 | export function addChildToNode(node, {tagName, attrs, content}) { 2 | const child = document.createElement(tagName); 3 | if (attrs) { 4 | Object.keys(attrs).forEach(key => { 5 | child.setAttribute(key, attrs[key]); 6 | }); 7 | } 8 | if (content) { 9 | child.innerHTML = content; 10 | } 11 | node.appendChild(child); 12 | return child; 13 | } 14 | 15 | export function removeChildFromNode(node) { 16 | while (node.firstChild) { 17 | node.removeChild(node.firstChild); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /karma.conf.ci.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | module.exports = function (config) { 3 | config.set({ 4 | singleRun: true 5 | }); 6 | 7 | if (process.env.USE_CLOUD === "true") { 8 | require("./karma.conf.cloud.js")(config); 9 | } else { 10 | console.log("running default test on Chrome"); 11 | config.set({ 12 | customLaunchers: { 13 | Chrome_CI: { 14 | base: "Chrome", 15 | flags: ["--no-sandbox"] 16 | } 17 | }, 18 | browsers: ["Chrome_CI"] 19 | }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /test/execSteps.js: -------------------------------------------------------------------------------- 1 | // borrowed from https://github.com/rackt/react-router/blob/master/modules/__tests__/execSteps.js 2 | function execSteps(steps, done) { 3 | let index = 0; 4 | 5 | return function(...args) { 6 | if (steps.length === 0) { 7 | done(); 8 | } else { 9 | try { 10 | steps[index++].apply(this, args); 11 | 12 | if (index === steps.length) { 13 | done(); 14 | } 15 | } catch (error) { 16 | done(error); 17 | } 18 | } 19 | }; 20 | } 21 | 22 | export default execSteps; 23 | -------------------------------------------------------------------------------- /src/react/isLocationValid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `location` is a plain object which represents the current location in browser similar to document.location. 3 | * see https://github.com/rackt/history/blob/master/docs/Location.md 4 | * 5 | * @method isLocationValid 6 | * @param location 7 | * @returns {boolean} 8 | */ 9 | export default function isLocationValid(location) { 10 | return ( 11 | !!location && 12 | typeof location === "object" && 13 | location.hasOwnProperty("pathname") && 14 | location.hasOwnProperty("hash") && 15 | location.hasOwnProperty("search") && 16 | location.hasOwnProperty("state") 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /examples/react-router/basic/user.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import {exposeMetrics} from "react-metrics"; // eslint-disable-line import/no-unresolved 4 | 5 | @exposeMetrics class User extends React.Component { 6 | static propTypes = { 7 | params: PropTypes.object 8 | }; 9 | 10 | static willTrackPageView(routeState) { 11 | return routeState.params; 12 | } 13 | 14 | render() { 15 | const {id} = this.props.params; 16 | return ( 17 |
18 |

User id: {id}

19 |
20 | ); 21 | } 22 | } 23 | export default User; 24 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ### React Metrics Exports 4 | 5 | * [metrics(config, [options])(Component)](/docs/api/ReactMetrics.md#metrics) 6 | * [exposeMetrics](/docs/api/ReactMetrics.md#exposeMetrics) 7 | * [PropTypes](/docs/api/ReactMetrics.md#PropTypes) 8 | * [createMetrics(config)](/docs/api/ReactMetrics.md#createMetrics) 9 | * [MetricsElement](/docs/api/ReactMetrics.md#MetricsElement) 10 | 11 | ### Metrics API 12 | 13 | * [listen(callback)](/docs/api/Core.md#listen) 14 | * [setRouteState(routeState)](/docs/api/Core.md#setRouteState) 15 | * [useTrackBinding([rootElement], [attributePrefix])](/docs/api/Core.md#useTrackBinding) 16 | * [destroy()](/docs/api/Core.md#destroy) 17 | * [api](/docs/api/Core.md#api) 18 | -------------------------------------------------------------------------------- /examples/redux/basic/counter.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from "./ActionTypes"; 2 | 3 | export default function counter(state, action) { 4 | const {id} = action; 5 | let nextState; 6 | switch (action.type) { 7 | case ActionTypes.INCLEMENT: 8 | nextState = { 9 | ...state, 10 | [`counter${id}`]: state[`counter${id}`] + 1 11 | }; 12 | return nextState; 13 | case ActionTypes.DECLEMENT: 14 | nextState = { 15 | ...state, 16 | [`counter${id}`]: state[`counter${id}`] - 1 17 | }; 18 | return nextState; 19 | default: 20 | return state; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-nfl", "prettier"], 3 | "env": { 4 | "mocha": true 5 | }, 6 | "globals": { 7 | "expect": true, 8 | "sinon": true 9 | }, 10 | "plugins": [ 11 | "prettier" 12 | ], 13 | "parser": "babel-eslint", 14 | "rules": { 15 | "prettier/prettier": ["error", { 16 | "printWidth": 80, 17 | "tabWidth": 4, 18 | "singleQuote": false, 19 | "trailingComma": "none", 20 | "bracketSpacing": false, 21 | "semi": true, 22 | "useTabs": false, 23 | "parser": "babylon", 24 | "jsxBracketSameLine": false 25 | } 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/TestService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Metrics service class for testing. 3 | * @module TestService 4 | * @class 5 | * @internal 6 | */ 7 | class TestService { 8 | constructor() { 9 | this.name = "Test Service"; 10 | } 11 | pageView(...args) { 12 | return this.track(...args); 13 | } 14 | /** 15 | * 16 | * @method track 17 | * @param {String} eventName 18 | * @param {Object} params 19 | * @returns {Promise} 20 | * @internal 21 | */ 22 | track(eventName, params) { 23 | return new Promise(resolve => { 24 | resolve({ 25 | eventName, 26 | params 27 | }); 28 | }); 29 | } 30 | } 31 | 32 | export default TestService; 33 | -------------------------------------------------------------------------------- /examples/react-router/basic/manual-page-view.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import exposeMetrics from "react-metrics"; // eslint-disable-line import/no-unresolved 3 | import PropTypes from "prop-types"; 4 | 5 | @exposeMetrics class ManualPageView extends React.Component { 6 | static contextTypes = { 7 | metrics: PropTypes.metrics 8 | }; 9 | 10 | static propTypes = { 11 | appName: PropTypes.string 12 | }; 13 | 14 | static willTrackPageView() { 15 | return false; 16 | } 17 | 18 | componentDidMount() { 19 | const {appName} = this.props; 20 | this.context.metrics.pageView({appName}); 21 | } 22 | 23 | render() { 24 | return

Manual Page View Example

; 25 | } 26 | } 27 | 28 | export default ManualPageView; 29 | -------------------------------------------------------------------------------- /src/core/utils/attr2obj.js: -------------------------------------------------------------------------------- 1 | const attr2obj = (elem, prefix = "data") => { 2 | const attrs = elem.attributes; 3 | const dataAttrs = {}; 4 | const rdashAlpha = /-([\da-z])/gi; 5 | const fcamelCase = (all, letter) => { 6 | return letter.toUpperCase(); 7 | }; 8 | const camelCase = string => { 9 | return string.replace(rdashAlpha, fcamelCase); 10 | }; 11 | 12 | if (elem.nodeType === 1) { 13 | let i = attrs.length; 14 | while (i--) { 15 | const name = attrs[i].name; 16 | if (name.indexOf(`${prefix}-`) === 0) { 17 | const camelName = camelCase(name.slice(prefix.length + 1)); 18 | dataAttrs[camelName] = elem.getAttribute(name); 19 | } 20 | } 21 | } 22 | return dataAttrs; 23 | }; 24 | 25 | export default attr2obj; 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "node" 5 | cache: 6 | directories: 7 | - node_modules 8 | env: 9 | global: 10 | - BROWSER_PROVIDER_READY_FILE=/tmp/sauce-connect-ready 11 | - LOGS_DIR=/tmp/react-metrics-build/logs 12 | - USE_CLOUD=false 13 | matrix: 14 | fast_finish: true 15 | include: 16 | - node_js: stable 17 | env: USE_CLOUD=true 18 | allow_failures: 19 | - env: USE_CLOUD=true 20 | before_install: 21 | - export CHROME_BIN=chromium-browser 22 | - export DISPLAY=:99.0 23 | - sh -e /etc/init.d/xvfb start 24 | before_script: 25 | - mkdir -p $LOGS_DIR 26 | - ./scripts/setup.sh 27 | script: 28 | - npm test 29 | - npm run bundlesize 30 | after_script: 31 | - ./scripts/teardown.sh 32 | after_success: 33 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 34 | -------------------------------------------------------------------------------- /examples/react-router/basic/async-page-view.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {PropTypes, exposeMetrics} from "react-metrics"; // eslint-disable-line import/no-unresolved 3 | 4 | @exposeMetrics class AsyncPageView extends React.Component { 5 | static contextTypes = { 6 | metrics: PropTypes.metrics 7 | }; 8 | 9 | static willTrackPageView(routeState) { 10 | return AsyncPageView._promise.then(result => { 11 | return Object.assign(result, routeState.query); 12 | }); 13 | } 14 | 15 | static _promise = new Promise(resolve => { 16 | setTimeout(() => { 17 | resolve({ 18 | asyncMetrics: "asyncValue" 19 | }); 20 | }, 5 * 1000); 21 | }); 22 | 23 | render() { 24 | return

Async Page View Example

; 25 | } 26 | } 27 | 28 | export default AsyncPageView; 29 | -------------------------------------------------------------------------------- /src/react/getRouteState.js: -------------------------------------------------------------------------------- 1 | import locationEquals from "./locationEquals"; 2 | import isLocationValid from "./isLocationValid"; 3 | 4 | function mergeParam(location, params) { 5 | return Object.keys(location).reduce( 6 | (obj, key) => { 7 | obj[key] = location[key]; 8 | return obj; 9 | }, 10 | {params} 11 | ); 12 | } 13 | 14 | export default function getRouteState(newProps, props = {}) { 15 | if ( 16 | isLocationValid(newProps.location) && 17 | !locationEquals(props.location, newProps.location) 18 | ) { 19 | // additional params for the dynamic segments of the URL if available. 20 | const {location, params} = newProps; 21 | if (location.params || !params) { 22 | return location; 23 | } 24 | const routeState = mergeParam(location, params); 25 | return routeState; 26 | } 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /test/ReactMetrics/getRouteState.spec.js: -------------------------------------------------------------------------------- 1 | import getRouteState from "../../src/react/getRouteState"; 2 | 3 | describe("getRouteState", () => { 4 | it("should return new location when param is included", () => { 5 | const newProps = { 6 | location: { 7 | pathname: "/", 8 | hash: "", 9 | search: "", 10 | state: null 11 | }, 12 | params: { 13 | param: "value" 14 | } 15 | }; 16 | expect(getRouteState(newProps)).to.not.eql(newProps.location); 17 | 18 | const newProps2 = { 19 | location: { 20 | pathname: "/", 21 | hash: "", 22 | search: "", 23 | state: null, 24 | params: { 25 | param: "value" 26 | } 27 | } 28 | }; 29 | expect(getRouteState(newProps2)).to.equal(newProps2.location); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var env = process.env.NODE_ENV; 3 | 4 | var reactExternal = { 5 | root: "React", 6 | commonjs2: "react", 7 | commonjs: "react", 8 | amd: "react" 9 | }; 10 | 11 | var reactDomExternal = { 12 | root: "ReactDOM", 13 | commonjs2: "react-dom", 14 | commonjs: "react-dom", 15 | amd: "react-dom" 16 | }; 17 | 18 | var config = { 19 | externals: { 20 | "react": reactExternal, 21 | "react-dom": reactDomExternal 22 | }, 23 | module: { 24 | loaders: [ 25 | {test: /\.js$/, loaders: ["babel-loader"], exclude: /node_modules/} 26 | ] 27 | }, 28 | output: { 29 | library: "ReactMetrics", 30 | libraryTarget: "umd" 31 | }, 32 | plugins: [ 33 | new webpack.optimize.OccurenceOrderPlugin(), 34 | new webpack.DefinePlugin({ 35 | "process.env.NODE_ENV": JSON.stringify(env) 36 | }) 37 | ] 38 | }; 39 | 40 | module.exports = config; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 NFL 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/core/createService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import ActionTypes from "./ActionTypes"; 3 | import {aggregateApisByType} from "./utils/extractApis"; 4 | 5 | const noop = function() {}; 6 | 7 | function isService(obj) { 8 | const functionProps = aggregateApisByType(obj); 9 | return functionProps.length > 0; 10 | } 11 | 12 | export const defaultService = { 13 | [ActionTypes.PAGE_VIEW]: noop, 14 | [ActionTypes.TRACK]: noop 15 | }; 16 | 17 | export default function createService(options = {}) { 18 | const {api} = options; 19 | let instance = defaultService; 20 | function instantiate() { 21 | const ClassType = api; 22 | return new ClassType(options); 23 | } 24 | if (typeof api === "function") { 25 | let inst; 26 | try { 27 | inst = api(options); 28 | } catch (err) { 29 | } finally { 30 | if (!inst || !isService(inst)) { 31 | inst = instantiate(); 32 | } 33 | if (isService(inst)) { 34 | instance = inst; 35 | } 36 | } 37 | } else if (api && typeof api === "object" && isService(api)) { 38 | instance = api; 39 | } 40 | const name = options.name || instance.name; 41 | return {name, apis: instance}; 42 | } 43 | -------------------------------------------------------------------------------- /examples/redux/basic/metricsMiddleware.js: -------------------------------------------------------------------------------- 1 | import {createMetrics} from "react-metrics"; // eslint-disable-line import/no-unresolved 2 | import MetricsConfig from "./metrics.config"; 3 | import ActionTypes from "./ActionTypes"; 4 | 5 | const metrics = createMetrics({ 6 | ...MetricsConfig, 7 | debug: true 8 | }); 9 | 10 | export default function metricsMiddleware({getState}) { 11 | return next => action => { 12 | const returnValue = next(action); 13 | switch (action.type) { 14 | case ActionTypes.INCLEMENT: 15 | case ActionTypes.DECLEMENT: 16 | const {id} = action; 17 | const state = getState(); 18 | metrics.api.track("counterClick", { 19 | id, 20 | value: state[`counter${id}`] 21 | }); 22 | break; 23 | case ActionTypes.ROUTE_CHANGE: 24 | const {location} = action; 25 | const paths = location.pathname.substr(1).split("/"); 26 | const routeState = location; 27 | metrics.setRouteState(routeState); 28 | metrics.api.pageView({ 29 | category: !paths[0] ? "landing" : paths[0] 30 | }); 31 | } 32 | return returnValue; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/utils/extractApis.js: -------------------------------------------------------------------------------- 1 | const EXCLUDES = ["constructor"].concat( 2 | Object.getOwnPropertyNames(Object.getPrototypeOf({})) 3 | ); 4 | 5 | export function filterKeysByType(obj, total = [], type = "function") { 6 | return Object.getOwnPropertyNames(obj).filter(key => { 7 | return ( 8 | total.indexOf(key) === -1 && 9 | EXCLUDES.indexOf(key) === -1 && 10 | key.indexOf("_") !== 0 && // consider it's private 11 | obj.hasOwnProperty(key) && 12 | typeof obj[key] === type 13 | ); 14 | }); 15 | } 16 | 17 | export function aggregateApisByType(obj, total = []) { 18 | const keys = []; 19 | while (obj !== null) { 20 | // eslint-disable-line no-eq-null 21 | const arr = filterKeysByType(obj, total); 22 | keys.push(...arr); 23 | obj = Object.getPrototypeOf(obj); 24 | } 25 | return keys; 26 | } 27 | 28 | // extracts lists of methods from each service object. 29 | export default function extractApis(services) { 30 | services = Array.isArray(services) ? services : [services]; 31 | const apis = services.reduce((total, service) => { 32 | const obj = service.constructor === Object 33 | ? service 34 | : Object.getPrototypeOf(service); 35 | const keys = aggregateApisByType(obj, total); 36 | total.push(...keys); 37 | return total; 38 | }, []); 39 | 40 | return apis; 41 | } 42 | -------------------------------------------------------------------------------- /examples/no-router-lib/basic/page.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import {PropTypes as MetricsPropTypes} from "react-metrics"; // eslint-disable-line import/no-unresolved 5 | 6 | class Page extends React.Component { 7 | constructor(...args) { 8 | super(...args); 9 | 10 | this.onClick = this.onClick.bind(this); 11 | } 12 | 13 | static contextTypes = { 14 | metrics: MetricsPropTypes.metrics, 15 | appState: PropTypes.any 16 | }; 17 | 18 | static propTypes = { 19 | params: PropTypes.object 20 | }; 21 | 22 | onClick() { 23 | this.context.metrics.user({ 24 | username: "exampleuser" 25 | }); 26 | const {params} = this.props; 27 | this.context.metrics.track( 28 | "trackClick", 29 | {page: params.id}, 30 | true /* this will merge page default metrics */ 31 | ); 32 | } 33 | 34 | render() { 35 | const {params} = this.props; 36 | return ( 37 |
38 |

Page {params.id}

39 | 40 | 47 |
48 | ); 49 | } 50 | } 51 | 52 | export default Page; 53 | -------------------------------------------------------------------------------- /examples/redux/basic/metrics.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "../../../package.json"; 2 | const config = { 3 | vendors: [ 4 | { 5 | api: { 6 | name: "Test", 7 | pageView(eventName, params) { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve({ 11 | eventName, 12 | params 13 | }); 14 | }, 0 * 1000); 15 | }); 16 | }, 17 | track(eventName, params) { 18 | return new Promise(resolve => { 19 | resolve({ 20 | eventName, 21 | params 22 | }); 23 | }); 24 | }, 25 | user(user) { 26 | return new Promise(resolve => { 27 | resolve({ 28 | user 29 | }); 30 | }); 31 | } 32 | } 33 | } 34 | ], 35 | pageDefaults: () => { 36 | // const paths = routeState.pathname.substr(1).split("/"); 37 | const timestamp = new Date(); 38 | return { 39 | timestamp, 40 | build: pkg.version, 41 | siteName: "React Metrics Example" 42 | }; 43 | }, 44 | pageViewEvent: "pageLoad" 45 | }; 46 | 47 | export default config; 48 | -------------------------------------------------------------------------------- /examples/react-router/basic/metrics.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "../../../package.json"; 2 | const config = { 3 | vendors: [ 4 | { 5 | api: { 6 | name: "Test", 7 | pageView(eventName, params) { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve({ 11 | eventName, 12 | params 13 | }); 14 | }, 0 * 1000); 15 | }); 16 | }, 17 | track(eventName, params) { 18 | return new Promise(resolve => { 19 | resolve({ 20 | eventName, 21 | params 22 | }); 23 | }); 24 | }, 25 | user(user) { 26 | return new Promise(resolve => { 27 | resolve({ 28 | user 29 | }); 30 | }); 31 | } 32 | } 33 | } 34 | ], 35 | pageDefaults: routeState => { 36 | const paths = routeState.pathname.substr(1).split("/"); 37 | const timestamp = new Date(); 38 | return { 39 | timestamp, 40 | build: pkg.version, 41 | siteName: "React Metrics Example", 42 | category: !paths[0] ? "landing" : paths[0] 43 | }; 44 | }, 45 | pageViewEvent: "pageLoad", 46 | debug: true 47 | }; 48 | 49 | export default config; 50 | -------------------------------------------------------------------------------- /examples/react-router/metricsElement/metrics.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "../../../package.json"; 2 | const config = { 3 | vendors: [ 4 | { 5 | api: { 6 | name: "Test", 7 | pageView(eventName, params) { 8 | return new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve({ 11 | eventName, 12 | params 13 | }); 14 | }, 0 * 1000); 15 | }); 16 | }, 17 | track(eventName, params) { 18 | return new Promise(resolve => { 19 | resolve({ 20 | eventName, 21 | params 22 | }); 23 | }); 24 | }, 25 | user(user) { 26 | return new Promise(resolve => { 27 | resolve({ 28 | user 29 | }); 30 | }); 31 | } 32 | } 33 | } 34 | ], 35 | pageDefaults: routeState => { 36 | const paths = routeState.pathname.substr(1).split("/"); 37 | const timestamp = new Date(); 38 | return { 39 | timestamp, 40 | build: pkg.version, 41 | siteName: "React Metrics Example", 42 | category: !paths[0] ? "landing" : paths[0] 43 | }; 44 | }, 45 | pageViewEvent: "pageLoad", 46 | debug: true 47 | }; 48 | 49 | export default config; 50 | -------------------------------------------------------------------------------- /src/react/exposeMetrics.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import {canUseDOM} from "fbjs/lib/ExecutionEnvironment"; 3 | import hoistStatics from "hoist-non-react-statics"; 4 | import PropTypes from "./PropTypes"; 5 | 6 | const mountedInstances = []; 7 | 8 | export function getMountedInstances() { 9 | return mountedInstances; 10 | } 11 | 12 | // convenient method for unit test 13 | export function clearMountedInstances() { 14 | mountedInstances.length = 0; 15 | } 16 | 17 | function getDisplayName(Comp) { 18 | return Comp.displayName || Comp.name || "Component"; 19 | } 20 | 21 | function wrap(ComposedComponent) { 22 | class Metrics extends Component { 23 | static displayName = `Metrics(${getDisplayName(ComposedComponent)})`; 24 | 25 | // context unit test fails w/o this, why?? 26 | static contextTypes = { 27 | metrics: PropTypes.metrics 28 | }; 29 | 30 | componentWillMount() { 31 | if (!canUseDOM) { 32 | return; 33 | } 34 | 35 | mountedInstances.push(Metrics); 36 | } 37 | 38 | componentWillUnmount() { 39 | const index = mountedInstances.indexOf(this); 40 | mountedInstances.splice(index, 1); 41 | } 42 | 43 | render() { 44 | return ; 45 | } 46 | } 47 | return hoistStatics(Metrics, ComposedComponent); 48 | } 49 | 50 | export default function useMetrics(...args) { 51 | if (typeof args[0] === "function") { 52 | return wrap(...args); 53 | } 54 | 55 | return target => { 56 | return wrap(target, ...args); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /examples/no-router-lib/basic/metrics.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "../../../package.json"; 2 | import GoogleAnalytics from "../../vendors/GoogleAnalytics"; 3 | 4 | const config = { 5 | vendors: [ 6 | { 7 | name: "Test Override", 8 | api: { 9 | name: "Test", 10 | pageView(eventName, params) { 11 | return new Promise(resolve => { 12 | resolve({ 13 | eventName, 14 | params 15 | }); 16 | }); 17 | }, 18 | track(eventName, params) { 19 | return new Promise(resolve => { 20 | resolve({ 21 | eventName, 22 | params 23 | }); 24 | }); 25 | }, 26 | user(user) { 27 | return new Promise(resolve => { 28 | // reject(new Error("dummy error Test")); 29 | resolve({ 30 | user 31 | }); 32 | }); 33 | } 34 | } 35 | }, 36 | { 37 | api: new GoogleAnalytics({ 38 | trackingId: "UA-68539421-1" 39 | }) 40 | } 41 | ], 42 | pageDefaults: routeState => { 43 | const paths = routeState.pathname.substr(1).split("/"); 44 | const timestamp = new Date(); 45 | return { 46 | timestamp, 47 | build: pkg.version, 48 | siteName: "React Metrics Example", 49 | category: !paths[0] ? "landing" : paths[0] 50 | }; 51 | }, 52 | pageViewEvent: "pageLoad", 53 | debug: true 54 | }; 55 | 56 | export default config; 57 | -------------------------------------------------------------------------------- /test/metrics.config.js: -------------------------------------------------------------------------------- 1 | import TestService from "./TestService"; 2 | 3 | export default { 4 | vendors: [ 5 | { 6 | name: "Test Service", 7 | api: TestService 8 | }, 9 | { 10 | name: "Another Service", 11 | api: { 12 | pageView(eventName, params) { 13 | return new Promise(resolve => { 14 | resolve({ 15 | eventName, 16 | params 17 | }); 18 | }); 19 | }, 20 | track(eventName, params) { 21 | return new Promise(resolve => { 22 | resolve({ 23 | eventName, 24 | params 25 | }); 26 | }); 27 | }, 28 | identify(user) { 29 | return new Promise(resolve => { 30 | resolve({ 31 | user 32 | }); 33 | }); 34 | }, 35 | someMethod() { 36 | return new Promise(resolve => { 37 | resolve(); 38 | }); 39 | }, 40 | argumentTest(a, b, c) { 41 | console.log(`argumentTest ${a} ${b} ${c}`, b); 42 | return new Promise(resolve => { 43 | resolve(); 44 | }); 45 | } 46 | } 47 | } 48 | ], 49 | pageDefaults: () => { 50 | return { 51 | country: "A", 52 | profileId: "B", 53 | socialAccountsActive: "C", 54 | siteSection: "D", 55 | siteSubsection: "E", 56 | siteExperience: "F", 57 | buildNumber: "G", 58 | dateTime: "H" 59 | }; 60 | }, 61 | pageViewEvent: "pageLoad" 62 | }; 63 | -------------------------------------------------------------------------------- /scripts/sauce/sauce_connect_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e -o pipefail 4 | 5 | # Setup and start Sauce Connect for your TravisCI build 6 | # This script requires your .travis.yml to include the following two private env variables: 7 | # SAUCE_USERNAME 8 | # SAUCE_ACCESS_KEY 9 | # Follow the steps at https://saucelabs.com/opensource/travis to set that up. 10 | # 11 | # Curl and run this script as part of your .travis.yml before_script section: 12 | # before_script: 13 | # - curl https://gist.github.com/santiycr/5139565/raw/sauce_connect_setup.sh | bash 14 | 15 | CONNECT_URL="https://saucelabs.com/downloads/sc-4.3.11-linux.tar.gz" 16 | CONNECT_DIR="/tmp/sauce-connect-$RANDOM" 17 | CONNECT_DOWNLOAD="sc-latest-linux.tar.gz" 18 | 19 | CONNECT_LOG="$LOGS_DIR/sauce-connect" 20 | CONNECT_STDOUT="$LOGS_DIR/sauce-connect.stdout" 21 | CONNECT_STDERR="$LOGS_DIR/sauce-connect.stderr" 22 | 23 | # Get Connect and start it 24 | mkdir -p $CONNECT_DIR 25 | cd $CONNECT_DIR 26 | curl $CONNECT_URL -o $CONNECT_DOWNLOAD 2> /dev/null 1> /dev/null 27 | mkdir sauce-connect 28 | tar --extract --file=$CONNECT_DOWNLOAD --strip-components=1 --directory=sauce-connect > /dev/null 29 | rm $CONNECT_DOWNLOAD 30 | 31 | if [ "$TRAVIS_PULL_REQUEST" != "false" ] || [ "$TRAVIS_BRANCH" != "master" ]; then 32 | SAUCE_USERNAME="$SAUCE_USERNAME_PR" 33 | SAUCE_ACCESS_KEY="$SAUCE_ACCESS_KEY_PR" 34 | fi 35 | 36 | echo "SAUCE_USERNAME: $SAUCE_USERNAME" 37 | 38 | ARGS="-B all" 39 | 40 | # Set tunnel-id only on Travis, to make local testing easier. 41 | if [ ! -z "$TRAVIS_JOB_NUMBER" ]; then 42 | ARGS="$ARGS --tunnel-identifier $TRAVIS_JOB_NUMBER" 43 | fi 44 | if [ ! -z "$BROWSER_PROVIDER_READY_FILE" ]; then 45 | ARGS="$ARGS --readyfile $BROWSER_PROVIDER_READY_FILE" 46 | fi 47 | 48 | 49 | echo "Starting Sauce Connect in the background, logging into:" 50 | echo " $CONNECT_LOG" 51 | echo " $CONNECT_STDOUT" 52 | echo " $CONNECT_STDERR" 53 | sauce-connect/bin/sc -u $SAUCE_USERNAME -k $SAUCE_ACCESS_KEY $ARGS \ 54 | --logfile $CONNECT_LOG 2> $CONNECT_STDERR 1> $CONNECT_STDOUT & 55 | -------------------------------------------------------------------------------- /test/ReactMetrics/locationEquals.spec.js: -------------------------------------------------------------------------------- 1 | import locationEquals from "../../src/react/locationEquals"; 2 | 3 | describe("locationEquals", () => { 4 | it("should check against both falsy value", () => { 5 | const a = null; 6 | const b = false; 7 | expect(locationEquals(a, b)).to.be.true; 8 | }); 9 | 10 | it("should correctly validate location equality", () => { 11 | const a = { 12 | pathname: "/a/b", 13 | search: "?param=value", 14 | state: { 15 | prop1: true, 16 | deepProp: { 17 | a: "a", 18 | b: "b" 19 | } 20 | } 21 | }; 22 | let b = { 23 | pathname: "/a/b", 24 | search: "?param=value", 25 | state: { 26 | prop1: true, 27 | deepProp: { 28 | a: "a", 29 | b: "b" 30 | } 31 | } 32 | }; 33 | expect(locationEquals(a, b)).to.be.true; 34 | b = { 35 | pathname: "/a/c", 36 | search: "?param=value", 37 | state: { 38 | prop1: true, 39 | deepProp: { 40 | a: "a", 41 | b: "b" 42 | } 43 | } 44 | }; 45 | expect(locationEquals(a, b)).to.be.false; 46 | b = { 47 | pathname: "/a/b", 48 | search: "?param=value2", 49 | state: { 50 | prop1: true, 51 | deepProp: { 52 | a: "a", 53 | b: "b" 54 | } 55 | } 56 | }; 57 | expect(locationEquals(a, b)).to.be.false; 58 | b = { 59 | pathname: "/a/b", 60 | search: "?param=value", 61 | state: { 62 | prop1: true, 63 | deepProp: { 64 | a: "a", 65 | b: "c" 66 | } 67 | } 68 | }; 69 | expect(locationEquals(a, b)).to.be.false; 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | 5 | function isDirectory(dir) { 6 | return fs.lstatSync(dir).isDirectory(); 7 | } 8 | 9 | module.exports = { 10 | devtool: "inline-source-map", 11 | 12 | entry: fs.readdirSync(__dirname).reduce((entries, dir) => { 13 | const isNodeModules = dir === "node_modules"; 14 | const isSrc = dir === "src"; 15 | const dirPath = path.join(__dirname, dir); 16 | if (!isNodeModules && !isSrc && isDirectory(dirPath)) { 17 | fs.readdirSync(dirPath).forEach(subdir => { 18 | if (isDirectory(path.join(dirPath, subdir))) { 19 | entries[[dir, subdir].join("_")] = [ 20 | "babel-polyfill", 21 | path.join(dirPath, subdir, "app.js") 22 | ]; 23 | } 24 | }); 25 | } 26 | 27 | return entries; 28 | }, {}), 29 | 30 | output: { 31 | path: "examples/__build__", 32 | filename: "[name].js", 33 | chunkFilename: "[id].chunk.js", 34 | publicPath: "/__build__/" 35 | }, 36 | 37 | module: { 38 | loaders: [ 39 | { 40 | test: /\.jsx?$/, 41 | exclude: /node_modules/, 42 | loader: "babel", 43 | query: { 44 | presets: ["es2015-without-strict", "stage-0", "react"], 45 | plugins: ["transform-decorators-legacy"] 46 | } 47 | }, 48 | { 49 | test: /\.json?$/, 50 | loader: "json-loader" 51 | } 52 | ] 53 | }, 54 | 55 | resolve: { 56 | alias: { 57 | "react-metrics": `${process.cwd()}/src` 58 | } 59 | }, 60 | 61 | cache: false, 62 | 63 | plugins: [ 64 | new webpack.optimize.CommonsChunkPlugin("shared.js"), 65 | new webpack.DefinePlugin({ 66 | "process.env.NODE_ENV": JSON.stringify( 67 | process.env.NODE_ENV || "development" 68 | ) 69 | }) 70 | ] 71 | }; 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | Please take a moment to review this document in order to make the contribution 4 | process easy and effective for everyone involved. 5 | 6 | _**Please Note:** These guidelines are adapted from [@necolas](https://github.com/necolas)'s 7 | [issue-guidelines](https://github.com/necolas/issue-guidelines) and serve as 8 | an excellent starting point for contributing to any open source project._ 9 | 10 | 11 | ## Pull requests 12 | 13 | Good pull requests - patches, improvements, new features - are a fantastic 14 | help. They should remain focused in scope and avoid containing unrelated 15 | commits. 16 | 17 | **Please ask first** before embarking on any significant pull request (e.g. 18 | implementing features, refactoring code, porting to a different language), 19 | otherwise you risk spending a lot of time working on something that the 20 | project's developers might not want to merge into the project. 21 | 22 | 23 | ## Development Process 24 | Here are some guidelines to making changes and preparing your PR: 25 | 26 | 1. Make your proposed changes to the repository, along with updating/adding test cases. 27 | 2. (Optional) If you prefer to also test your changes in a real application, you can do the following: 28 | 1. Run `npm link` in `react-metrics` repository. 29 | 2. `cd` to your favorite React application, run `npm link react-metrics` to point to your local repository. 30 | 3. Run your application to verify your changes. 31 | 3. Run `npm test` to verify all test cases pass. 32 | 4. Run `npm run lint` to verify there are no linting errors. 33 | 34 | 35 | ## Travis CI Build 36 | Travis CI build will test your PR before it is merged. Browser testing may not run on Travis for PR, so please test your PR with supported browsers locally before submitting PR. 37 | 38 | 39 | ## Contributor License Agreement (CLA) 40 | 41 | In order for your pull requests to be accepted, you must accept the [NFL Indivudal Contributor License Agreement](https://cla.nfl.com/agreements/nfl/react-metrics). 42 | 43 | Corporate contributors can email engineers@nfl.com and request the **Corporate CLA** which can be signed digitally. 44 | 45 | -------------------------------------------------------------------------------- /docs/api/Core.md: -------------------------------------------------------------------------------- 1 | ## Metrics API References 2 | 3 | ### [`listen(callback)`](#listen) 4 | 5 | Once a client calls this, metrics will call `callback` with tracking event including type of API, request params and response payload whenever tracking event occurs. 6 | This will return a function to execute when unsubscribing. 7 | 8 | Example: 9 | 10 | ```javascript 11 | const metrics = createMetrics(config); 12 | const unsubscribe = metrics.listen(callback); 13 | 14 | // when you are done 15 | unsubscribe(); 16 | ``` 17 | 18 | ### [`setRouteState(routeState)`](#setRouteState) 19 | 20 | Notifies metrics of route change and passes new route state object. Metrics will try to cancel any currently pending promise which is passed from the client(Not the one service api returns) when this is called. 21 | The last set [`routeState`](/docs/Guides.md#routeState) will be passed to `pageDefaults` and `willTrackPageView` method, so if you need to access it from these methods, make sure you set it with the updated object. 22 | 23 | #### Arguments 24 | 25 | 1. [`routeState`](/docs/Guides.md#routeState): An object which includes properties of route information. 26 | 27 | ``` 28 | pathname 29 | search 30 | state 31 | query (optional) 32 | params (optional) 33 | ``` 34 | 35 | ### [`useTrackBinding([rootElement], [attributePrefix])`](#useTrackBinding) 36 | 37 | Calling this will enable declarative click tracking on element with custom tracking attributes. 38 | This will return a function to execute when unsubscribing. 39 | 40 | Example: 41 | 42 | ```javascript 43 | const metrics = createMetrics(config); 44 | const unsubscribe = metrics.useTrackBinding(); 45 | 46 | // when you are done using 47 | unsubscribe(); 48 | 49 | // or passing false will have the same effect 50 | metrics.useTrackBinding(false); 51 | ``` 52 | 53 | #### Arguments 54 | 55 | 1. rootElement (optional): An element which is used as event delegation root. If omitted, `document.body` will be used. 56 | 2. attributePrefix (optional): an attribute prefix which will override the default `data-metrics`. 57 | 58 | ### [`destroy()`](#destroy) 59 | 60 | Unsubscribe all listeners inside metrics instance. 61 | 62 | ### [`api`](#api) 63 | 64 | A read-only object which exposes all APIs defined in your [`vendors`](/docs/Guides.md#vendor) configuration. 65 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | module.exports = function (config) { 3 | config.set({ 4 | basePath: "", 5 | 6 | browserNoActivityTimeout: 60000, 7 | 8 | client: { 9 | mocha: { 10 | reporter: "html" 11 | } 12 | }, 13 | 14 | frameworks: [ 15 | "chai-sinon", 16 | "mocha" 17 | ], 18 | 19 | files: [ 20 | "node_modules/babel-polyfill/dist/polyfill.js", 21 | "test/**/*spec.js" 22 | ], 23 | 24 | preprocessors: { 25 | "test/**/*spec.js": ["webpack", "sourcemap"] 26 | }, 27 | 28 | coverageReporter: { 29 | reporters: [{ 30 | type: "html", 31 | subdir: "html" 32 | }, { 33 | type: "text" 34 | }, { 35 | type: "lcovonly", 36 | subdir: "." 37 | }] 38 | }, 39 | 40 | webpack: { 41 | devtool: "inline-source-map", 42 | module: { 43 | loaders: [{ 44 | test: /\.js$/, 45 | exclude: /node_modules/, 46 | loader: "babel" 47 | }, { 48 | test: /\.js$/, 49 | // exclude this dirs from coverage 50 | exclude: /(test|node_modules)\//, 51 | loader: "isparta" 52 | }] 53 | }, 54 | resolve: { 55 | extensions: ["", ".js"] 56 | }, 57 | plugins: [ 58 | new webpack.DefinePlugin({ 59 | "process.env.NODE_ENV": JSON.stringify("production") 60 | }) 61 | ], 62 | watch: true 63 | }, 64 | 65 | webpackServer: { 66 | noInfo: true 67 | }, 68 | 69 | reporters: [ 70 | "mocha", 71 | "coverage" 72 | ], 73 | 74 | port: 9876, 75 | 76 | colors: true, 77 | 78 | logLevel: config.LOG_INFO, 79 | 80 | autoWatch: true, 81 | 82 | browsers: ["Chrome"], 83 | 84 | captureTimeout: 60000 85 | }); 86 | 87 | if (process.env.CI) { 88 | require("./karma.conf.ci.js")(config); 89 | } else if (process.env.USE_CLOUD === "true") { 90 | require("./karma.conf.cloud.js")(config); 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /examples/react-router/basic/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React, {Component} from "react"; 3 | import PropTypes from "prop-types"; 4 | import ReactDOM from "react-dom"; 5 | import { 6 | Router, 7 | Route, 8 | IndexRoute, 9 | Link, 10 | IndexLink, 11 | hashHistory 12 | } from "react-router"; 13 | import {metrics} from "react-metrics"; // eslint-disable-line import/no-unresolved 14 | import MetricsConfig from "./metrics.config"; 15 | import Home from "./home"; 16 | import AsyncPageView from "./async-page-view"; 17 | import ManualPageView from "./manual-page-view"; 18 | import User from "./user"; 19 | 20 | class App extends Component { 21 | static displayName = "My Application"; 22 | 23 | static propTypes = { 24 | children: PropTypes.node 25 | }; 26 | 27 | render() { 28 | return ( 29 |
30 | 43 | {this.props.children && 44 | React.cloneElement(this.props.children, { 45 | appName: App.displayName 46 | })} 47 |
48 | ); 49 | } 50 | } 51 | const DecoratedApp = metrics(MetricsConfig)(App); 52 | 53 | class NotFound extends Component { 54 | render() { 55 | return

404!

; 56 | } 57 | } 58 | 59 | ReactDOM.render( 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | , 69 | document.getElementById("example") 70 | ); 71 | -------------------------------------------------------------------------------- /examples/no-router-lib/basic/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React, {Component} from "react"; 3 | import PropTypes from "prop-types"; 4 | import ReactDOM from "react-dom"; 5 | import {metrics} from "react-metrics"; // eslint-disable-line import/no-unresolved 6 | import MetricsConfig from "./metrics.config"; 7 | import Home from "./home"; 8 | import Page from "./page"; 9 | import createHistory from "history/lib/createHashHistory"; 10 | 11 | @metrics(MetricsConfig) 12 | class App extends Component { 13 | static propTypes = { 14 | history: PropTypes.object, 15 | children: PropTypes.node 16 | }; 17 | 18 | render() { 19 | const createHref = this.props.history.createHref; 20 | return ( 21 |
22 | 27 | {this.props.children && 28 | React.cloneElement(this.props.children, {...this.props})} 29 |
30 | ); 31 | } 32 | } 33 | 34 | class AppContainer extends Component { 35 | constructor(props) { 36 | super(props); 37 | this.history = createHistory(); 38 | this.routes = { 39 | "/": {component: Home}, 40 | "/page/A": {component: Page, params: {id: "A"}}, 41 | "/page/B": {component: Page, params: {id: "B"}} 42 | }; 43 | this.state = { 44 | routeComponent: this.routes["/"].component 45 | }; 46 | } 47 | 48 | componentWillMount() { 49 | this.unlisten = this.history.listen(location => { 50 | const route = this.routes[location.pathname] || this.routes["/"]; 51 | const {component: routeComponent, params} = route; 52 | this.setState({routeComponent, location, params}); 53 | }); 54 | } 55 | 56 | componentWillUnmount() { 57 | this.unlisten(); 58 | } 59 | 60 | render() { 61 | return ( 62 | 67 | {React.createElement(this.state.routeComponent)} 68 | 69 | ); 70 | } 71 | } 72 | 73 | ReactDOM.render(, document.getElementById("example")); 74 | -------------------------------------------------------------------------------- /src/react/MetricsElement.js: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Children, 4 | createElement, 5 | cloneElement, 6 | isValidElement 7 | } from "react"; 8 | import PropTypes from "prop-types"; 9 | import ReactDOM from "react-dom"; 10 | import invariant from "fbjs/lib/invariant"; 11 | import warning from "fbjs/lib/warning"; 12 | import MetricsPropTypes from "./PropTypes"; 13 | import useTrackBindingPlugin from "../core/useTrackBindingPlugin"; 14 | 15 | export default class MetricsElement extends Component { 16 | static contextTypes = { 17 | metrics: MetricsPropTypes.metrics, 18 | _metrics: PropTypes.object, 19 | _metricsConfig: PropTypes.object 20 | }; 21 | 22 | static propTypes = { 23 | children: PropTypes.node, 24 | element: PropTypes.any 25 | }; 26 | 27 | componentDidMount() { 28 | const {metrics, _metricsConfig} = this.context; 29 | 30 | invariant( 31 | metrics, 32 | "MetricsElement requires metrics HOC to exist in the parent tree." 33 | ); 34 | 35 | const { 36 | useTrackBinding, 37 | attributePrefix, 38 | suppressTrackBindingWarning 39 | } = _metricsConfig; 40 | 41 | if (!suppressTrackBindingWarning) { 42 | warning( 43 | !useTrackBinding, 44 | "You are using 'MetricsElement' while default track binding is turned on. " + 45 | "It is recommended that you stick with either one to avoid double tracking accidentally. " + 46 | "If you intentionally use both and want to suppress this warning, pass 'suppressTrackBindingWarning=true' to the metrics options." 47 | ); 48 | } 49 | 50 | this._trackBindingListener = useTrackBindingPlugin({ 51 | callback: this._handleClick.bind(this), 52 | rootElement: ReactDOM.findDOMNode(this), 53 | attributePrefix, 54 | traverseParent: true 55 | }); 56 | } 57 | 58 | componentWillUnmount() { 59 | if (this._trackBindingListener) { 60 | this._trackBindingListener.remove(); 61 | this._trackBindingListener = null; 62 | } 63 | } 64 | 65 | _handleClick(...args) { 66 | this.context.metrics.track(...args); 67 | } 68 | 69 | render() { 70 | const {element, children, ...rest} = this.props; 71 | 72 | if (!element) { 73 | return Children.only(children); 74 | } 75 | 76 | return isValidElement(element) 77 | ? cloneElement(element, rest) 78 | : createElement(element, rest, children); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /examples/vendors/GoogleAnalytics.js: -------------------------------------------------------------------------------- 1 | import analytics from "analytics.js"; 2 | /** 3 | * Performs the tracking calls to Google Analytics. 4 | * Utilizing Segment IO Analytics Integration. 5 | * 6 | * @module GoogleAnalytics 7 | * @class 8 | * @internal 9 | */ 10 | class GoogleAnalytics { 11 | constructor(options = {}) { 12 | this.name = "Google Analytics"; 13 | this._loaded = false; 14 | this.options = options; 15 | } 16 | /** 17 | * 18 | * @method pageView 19 | * @param {String} eventName 20 | * @param {Object} params 21 | * @returns {Promise} 22 | * @internal 23 | */ 24 | pageView(...args) { 25 | return this.track(...args); 26 | } 27 | user(userId) { 28 | return new Promise(resolve => { 29 | this.userId = userId; 30 | resolve({ 31 | userId 32 | }); 33 | }); 34 | } 35 | /** 36 | * 37 | * @method track 38 | * @param {String} eventName 39 | * @param {Object} params 40 | * @returns {Promise} 41 | * @internal 42 | */ 43 | track(eventName, params) { 44 | return new Promise((resolve, reject) => { 45 | this._load() 46 | .then(() => { 47 | this._track(eventName, params); 48 | resolve({ 49 | eventName, 50 | params 51 | }); 52 | }) 53 | .catch(error => { 54 | console.error("GA: Failed to initialize", error); 55 | reject(error); 56 | }); 57 | }); 58 | } 59 | /** 60 | * 61 | * @method _track 62 | * @param {String} eventName 63 | * @param {Object} params 64 | * @protected 65 | */ 66 | _track(eventName, params) { 67 | if (eventName === "pageView") { 68 | analytics.page(params.category, params); 69 | return; 70 | } 71 | analytics.track(eventName, params); 72 | } 73 | /** 74 | * 75 | * @method _load 76 | * @protected 77 | */ 78 | _load() { 79 | return ( 80 | this._promise || 81 | (this._promise = new Promise(resolve => { 82 | if (this._loaded) { 83 | resolve(); 84 | } else { 85 | analytics.once("ready", () => { 86 | this._loaded = true; 87 | resolve(); 88 | }); 89 | analytics.initialize({ 90 | "Google Analytics": this.options 91 | }); 92 | } 93 | })) 94 | ); 95 | } 96 | } 97 | 98 | export default GoogleAnalytics; 99 | -------------------------------------------------------------------------------- /examples/vendors/GoogleTagManager.js: -------------------------------------------------------------------------------- 1 | import analytics from "analytics.js"; 2 | /** 3 | * Performs the tracking calls to Google Tag Manager. 4 | * Utilizing Segment IO Metrics Integration. 5 | * Note: Not currently working (containerId: GTM-P79HDJ). 6 | * 7 | * @module GoogleTagManager 8 | * @class 9 | * @internal 10 | */ 11 | class GoogleTagManager { 12 | constructor(options = {}) { 13 | this.name = "Google Tag Manager"; 14 | this._loaded = false; 15 | this.options = options; 16 | } 17 | /** 18 | * 19 | * @method pageView 20 | * @param {String} eventName 21 | * @param {Object} params 22 | * @returns {Promise} 23 | * @internal 24 | */ 25 | pageView(...args) { 26 | return this.track(...args); 27 | } 28 | user(user) { 29 | return new Promise(resolve => { 30 | // reject(new Error("dummy error")); 31 | resolve({ 32 | user 33 | }); 34 | }); 35 | } 36 | /** 37 | * 38 | * @method track 39 | * @param {String} eventName 40 | * @param {Object} params 41 | * @returns {Promise} 42 | * @internal 43 | */ 44 | track(eventName, params) { 45 | return new Promise((resolve, reject) => { 46 | this._load() 47 | .then(() => { 48 | this._track(eventName, params); 49 | resolve({ 50 | eventName, 51 | params 52 | }); 53 | }) 54 | .catch(error => { 55 | console.error("GTM: Failed to initialize", error); 56 | reject(error); 57 | }); 58 | }); 59 | } 60 | /** 61 | * 62 | * @method _track 63 | * @param {String} eventName 64 | * @param {Object} params 65 | * @protected 66 | */ 67 | _track(eventName, params) { 68 | if (eventName === "pageView") { 69 | analytics.page(params.category, params); 70 | return; 71 | } 72 | analytics.track(eventName, params); 73 | } 74 | /** 75 | * 76 | * @method _load 77 | * @protected 78 | */ 79 | _load() { 80 | return ( 81 | this._promise || 82 | (this._promise = new Promise(resolve => { 83 | if (this._loaded) { 84 | resolve(); 85 | } else { 86 | analytics.once("ready", () => { 87 | this._loaded = true; 88 | resolve(); 89 | }); 90 | analytics.initialize({ 91 | "Google Tag Manager": this.options 92 | }); 93 | } 94 | })) 95 | ); 96 | } 97 | } 98 | 99 | export default GoogleTagManager; 100 | -------------------------------------------------------------------------------- /examples/react-router/metricsElement/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React, {Component} from "react"; 3 | import PropTypes from "prop-types"; 4 | import ReactDOM from "react-dom"; 5 | import { 6 | Router, 7 | Route, 8 | IndexRoute, 9 | Link, 10 | IndexLink, 11 | hashHistory 12 | } from "react-router"; 13 | import {metrics, MetricsElement} from "react-metrics"; // eslint-disable-line import/no-unresolved 14 | import MetricsConfig from "./metrics.config"; 15 | import Home from "./home"; 16 | import Page from "./page"; 17 | class App extends Component { 18 | static displayName = "My Application"; 19 | 20 | static propTypes = { 21 | children: PropTypes.node 22 | }; 23 | 24 | render() { 25 | const link = ( 26 | 31 | Page A 32 | 33 | ); 34 | return ( 35 |
36 |
    37 |
  • 38 | 39 | 43 | Home 44 | 45 | 46 |
  • 47 |
  • 48 | 49 | This span won't render 50 | 51 |
  • 52 |
  • 53 | 59 | Page B 60 | 61 |
  • 62 |
63 | {this.props.children && 64 | React.cloneElement(this.props.children, { 65 | appName: App.displayName 66 | })} 67 |
68 | ); 69 | } 70 | } 71 | const DecoratedApp = metrics(MetricsConfig, { 72 | useTrackBinding: false, 73 | attributePrefix: "data-tracking" 74 | })(App); 75 | 76 | class NotFound extends Component { 77 | render() { 78 | return

404!

; 79 | } 80 | } 81 | 82 | ReactDOM.render( 83 | 84 | 85 | 86 | 87 | 88 | 89 | , 90 | document.getElementById("example") 91 | ); 92 | -------------------------------------------------------------------------------- /src/core/useTrackBindingPlugin.js: -------------------------------------------------------------------------------- 1 | import EventListener from "fbjs/lib/EventListener"; 2 | import attr2obj from "./utils/attr2obj"; 3 | 4 | function isLeftClickEvent(event) { 5 | return event.button === 0; 6 | } 7 | 8 | function isModifiedEvent(event) { 9 | return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); 10 | } 11 | 12 | export class TrackBindingPlugin { 13 | constructor( 14 | {attributePrefix = "data-metrics", traverseParent = false} = {} 15 | ) { 16 | this._attributePrefix = attributePrefix; 17 | this._traverseParent = traverseParent; 18 | } 19 | 20 | listen(callback, rootElement = document.body) { 21 | if (typeof callback !== "function") { 22 | throw new Error("callback needs to be a function."); 23 | } 24 | 25 | if (rootElement && rootElement.nodeType !== 1) { 26 | throw new Error("rootElement needs to be a valid node element."); 27 | } 28 | 29 | if (this._clickHandler) { 30 | this.remove(); 31 | } 32 | 33 | this._rootElement = rootElement; 34 | this._clickHandler = EventListener.listen( 35 | rootElement, 36 | "click", 37 | this._handleClick.bind(this, callback) 38 | ); 39 | 40 | return { 41 | target: this, 42 | remove: this.remove.bind(this) 43 | }; 44 | } 45 | 46 | remove() { 47 | if (this._clickHandler) { 48 | this._clickHandler.remove(); 49 | this._clickHandler = null; 50 | } 51 | } 52 | 53 | /** 54 | * A click handler to perform custom link tracking, any element with 'metrics-*' attribute will be tracked. 55 | * 56 | * @method _handleClick 57 | * @param {Object} event 58 | * @private 59 | */ 60 | _handleClick(callback, event) { 61 | if (isModifiedEvent(event) || !isLeftClickEvent(event)) { 62 | return; 63 | } 64 | 65 | let elem = event.target || event.srcElement; 66 | let dataset = this._getData(elem); 67 | 68 | if (this._traverseParent) { 69 | const rootElement = this._rootElement; 70 | while (elem !== rootElement) { 71 | elem = elem.parentElement || elem.parentNode; 72 | dataset = {...this._getData(elem), ...dataset}; 73 | } 74 | } 75 | 76 | if (!Object.keys(dataset).length) { 77 | return; 78 | } 79 | 80 | const eventName = dataset && dataset.eventName; 81 | const mergePagedefaults = dataset && dataset.mergePagedefaults; 82 | delete dataset.mergePagedefaults; 83 | 84 | if (eventName) { 85 | delete dataset.eventName; 86 | callback(eventName, dataset, mergePagedefaults === "true"); 87 | } 88 | } 89 | 90 | _getData(elem) { 91 | return attr2obj(elem, this._attributePrefix); 92 | } 93 | } 94 | 95 | export default function useTrackBindingPlugin({ 96 | callback, 97 | rootElement, 98 | attributePrefix, 99 | traverseParent 100 | }) { 101 | const trackBindingPlugin = new TrackBindingPlugin({ 102 | attributePrefix, 103 | traverseParent 104 | }); 105 | return trackBindingPlugin.listen(callback, rootElement); 106 | } 107 | -------------------------------------------------------------------------------- /examples/vendors/AdobeTagManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Performs the tracking calls to Adobe Tag Manager. 3 | * @module AdobeTagManager 4 | * @class 5 | * @internal 6 | */ 7 | class AdobeTagManager { 8 | constructor(options = {}) { 9 | this.options = options; 10 | this.name = "Adobe Tag Manager"; 11 | } 12 | /** 13 | * 14 | * @method pageView 15 | * @param {String} eventName 16 | * @param {Object} params 17 | * @returns {Promise} 18 | * @internal 19 | */ 20 | pageView(...args) { 21 | return this.track(...args); 22 | } 23 | /** 24 | * 25 | * @method track 26 | * @param {String} eventName 27 | * @param {Object} params 28 | * @returns {Promise} 29 | * @internal 30 | */ 31 | track(eventName, params) { 32 | return new Promise((resolve, reject) => { 33 | this._load() 34 | .then(satellite => { 35 | this._satellite = this._satellite || satellite; 36 | this._track(eventName, params); 37 | resolve({ 38 | eventName, 39 | params 40 | }); 41 | }) 42 | .catch(error => { 43 | console.error("Omniture: Failed to load seed file", error); 44 | reject(error); 45 | }); 46 | }); 47 | } 48 | /** 49 | * 50 | * @method _track 51 | * @param {String} eventName 52 | * @param {Object} params 53 | * @protected 54 | */ 55 | _track(eventName, params) { 56 | this._satellite.data.customVars = {}; 57 | this._satellite.setVar(params); 58 | this._satellite.track(eventName); 59 | } 60 | /** 61 | * 62 | * @method _load 63 | * @protected 64 | */ 65 | _load() { 66 | return ( 67 | this._promise || 68 | (this._promise = new Promise((resolve, reject) => { 69 | if (window._satellite) { 70 | resolve(window._satellite); 71 | } else { 72 | const script = document.createElement("script"); 73 | 74 | script.onload = () => { 75 | this._addPageBottom(); 76 | resolve(window._satellite); 77 | }; 78 | 79 | script.onerror = error => { 80 | reject(error); 81 | }; 82 | 83 | script.src = this.options.seedFile; 84 | document.head.appendChild(script); 85 | } 86 | })) 87 | ); 88 | } 89 | /** 90 | * 91 | * @method _addPageBottom 92 | * @protected 93 | */ 94 | _addPageBottom() { 95 | const body = document.body; 96 | const script = document.createElement("script"); 97 | // Lets add to page so Adobe consultant knows we've added the pageBottom() call. 98 | const scriptContent = ` 99 | "use strict"; 100 | 101 | var _satellite = window._satellite; 102 | 103 | if (_satellite) { 104 | _satellite.pageBottom(); 105 | } 106 | `; 107 | 108 | script.text = scriptContent; 109 | return body.appendChild(script); 110 | } 111 | } 112 | 113 | export default AdobeTagManager; 114 | -------------------------------------------------------------------------------- /examples/react-router/metricsElement/page.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from "react"; 3 | import PropTypes from "prop-types"; 4 | import {MetricsElement} from "react-metrics"; // eslint-disable-line import/no-unresolved 5 | 6 | // Note: `data-tracking` prefix is set in app.js as `attributePrefix` option 7 | 8 | class Page extends React.Component { 9 | static propTypes = { 10 | params: PropTypes.object 11 | }; 12 | 13 | render() { 14 | const {params} = this.props; 15 | const listItem = Array.from("123").map(key => ( 16 |
  • 22 | 26 |
  • 27 | )); 28 | 29 | return ( 30 |
    31 |

    Page {params.id}

    32 | {/* Ex 1: self target */} 33 | 39 | Link 40 | 41 | {/* Ex 2: render children only */} 42 | 43 | 47 | 52 | 53 | 54 | {/* Ex 3: event bubbling */} 55 | 60 | 65 | 66 | {/* Ex 4: multiple tracking items */} 67 | 71 | {listItem} 72 | 73 | {/* Ex 5: aggregate metrics data */} 74 | 75 |
    79 | 83 | 88 | 89 |
    90 | 91 |
    92 |
      93 | {listItem} 94 |
    95 |
    96 |
    97 |
    98 | ); 99 | } 100 | } 101 | 102 | export default Page; 103 | -------------------------------------------------------------------------------- /karma.conf.bs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | module.exports = function (config) { 3 | // https://www.browserstack.com/list-of-browsers-and-platforms 4 | // https://api.browserstack.com/4/browsers?flat=true 5 | // real mobile device test is not supported for `Automate` yet 6 | var customLaunchers = { 7 | BS_Chrome: { 8 | base: "BrowserStack", 9 | os: "Windows", 10 | os_version: "8.1", 11 | browser: "chrome", 12 | browser_version: "45.0" 13 | }, 14 | BS_Chrome_Latest: { 15 | base: "BrowserStack", 16 | os: "Windows", 17 | os_version: "8.1", 18 | browser: "chrome", 19 | browser_version: "46.0" 20 | }, 21 | BS_Firefox: { 22 | base: "BrowserStack", 23 | os: "Windows", 24 | os_version: "8.1", 25 | browser: "firefox", 26 | browser_version: "41.0" 27 | }, 28 | BS_Firefox_Latest: { 29 | base: "BrowserStack", 30 | os: "Windows", 31 | os_version: "8.1", 32 | browser: "firefox", 33 | browser_version: "42.0" 34 | }, 35 | BS_iOS8_Safari: { 36 | base: "BrowserStack", 37 | os: "ios", 38 | os_version: "8.3", 39 | browser: "Mobile Safari", 40 | device: "iPhone 6 Plus", 41 | real_mobile: false 42 | }, 43 | BS_iOS9_Safari: { 44 | base: "BrowserStack", 45 | os: "ios", 46 | os_version: "9.0", 47 | browser: "Mobile Safari", 48 | device: "iPhone 6S Plus", 49 | real_mobile: false 50 | }, 51 | BS_OSX_Safari8: { 52 | base: "BrowserStack", 53 | os: "OS X", 54 | os_version: "Yosemite", 55 | browser: "safari", 56 | browser_version: "8.0" 57 | }, 58 | BS_OSX_Safari9: { 59 | base: "BrowserStack", 60 | os: "OS X", 61 | os_version: "El Capitan", 62 | browser: "safari", 63 | browser_version: "9.0" 64 | }, 65 | BS_InternetExplorer10: { 66 | base: "BrowserStack", 67 | os: "Windows", 68 | os_version: "8", 69 | browser: "ie", 70 | browser_version: "10.0" 71 | }, 72 | BS_InternetExplorer11: { 73 | base: "BrowserStack", 74 | os: "Windows", 75 | os_version: "8.1", 76 | browser: "ie", 77 | browser_version: "11.0" 78 | }, 79 | BS_Edge: { 80 | base: "BrowserStack", 81 | os: "Windows", 82 | os_version: "10", 83 | browser: "edge", 84 | browser_version: "12.0" 85 | }, 86 | BS_ANDROID4: { 87 | base: "BrowserStack", 88 | os: "android", 89 | os_version: "4.4", 90 | browser: "Android Browser" 91 | }/*, 92 | BS_ANDROID5: { // BrowserStack says this does not work as Android 5.x doesn't support communication via proxy 93 | base: "BrowserStack", 94 | os: "android", 95 | os_version: "5.0", 96 | browser: "Android Browser" 97 | }*/ 98 | }; 99 | 100 | config.set({ 101 | captureTimeout: 180000, 102 | browserNoActivityTimeout: 60000, 103 | browserDisconnectTimeout: 10000, 104 | browserDisconnectTolerance: 2, 105 | browserStack: { 106 | project: "React Metrics", 107 | pollingTimeout: 10000, 108 | startTunnel: true 109 | }, 110 | customLaunchers: customLaunchers, 111 | browsers: Object.keys(customLaunchers), 112 | reporters: ["dots", "coverage"] 113 | }); 114 | 115 | if (process.env.TRAVIS) { 116 | config.browserStack.name = process.env.TRAVIS_JOB_NUMBER; 117 | config.browserStack.build = "TRAVIS #" + process.env.TRAVIS_BUILD_NUMBER + " (" + process.env.TRAVIS_BUILD_ID + ")"; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-metrics", 3 | "description": "An analytics library for React.js", 4 | "version": "2.4.1", 5 | "main": "lib/index.js", 6 | "contributors": [ 7 | { 8 | "name": "NFL Engineering" 9 | } 10 | ], 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/nfl/react-metrics" 15 | }, 16 | "keywords": [ 17 | "react-metrics", 18 | "nfl", 19 | "react", 20 | "metrics", 21 | "analytics", 22 | "tracking" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/nfl/react-metrics/issues" 26 | }, 27 | "files": [ 28 | "*.md", 29 | "docs", 30 | "src", 31 | "lib", 32 | "dist" 33 | ], 34 | "dependencies": { 35 | "deep-equal": "^1.0.1", 36 | "eventemitter3": "^1.1.1", 37 | "fbjs": "^0.8.4", 38 | "hoist-non-react-statics": "^1.0.5", 39 | "querystring": "^0.2.0", 40 | "rimraf": "^2.5.1" 41 | }, 42 | "devDependencies": { 43 | "analytics.js": "^2.9.1", 44 | "babel-cli": "^6.5.1", 45 | "babel-core": "^6.5.1", 46 | "babel-eslint": "^7.2.3", 47 | "babel-loader": "^6.2.3", 48 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 49 | "babel-polyfill": "^6.7.4", 50 | "babel-preset-es2015-without-strict": "0.0.2", 51 | "babel-preset-react": "^6.5.0", 52 | "babel-preset-stage-0": "^6.5.0", 53 | "bundlesize": "^0.15.3", 54 | "chai": "^3.5.0", 55 | "codecov.io": "^0.1.6", 56 | "commitizen": "^2.5.0", 57 | "conventional-changelog-cli": "^1.1.1", 58 | "cz-conventional-changelog": "^1.1.5", 59 | "eslint": "^3.19.0", 60 | "eslint-config-nfl": "^11.1.0", 61 | "eslint-config-prettier": "^2.0.0", 62 | "eslint-plugin-import": "^2.2.0", 63 | "eslint-plugin-jsx-a11y": "^3.0.2", 64 | "eslint-plugin-mocha": "^4.8.0", 65 | "eslint-plugin-prettier": "^2.0.1", 66 | "eslint-plugin-react": "^7.1.0", 67 | "estraverse": "4.2.0", 68 | "estraverse-fb": "1.3.1", 69 | "history": "^2.0.0", 70 | "isparta-loader": "^2.0.0", 71 | "json-loader": "^0.5.3", 72 | "karma": "^0.13.10", 73 | "karma-browserstack-launcher": "taak77/karma-browserstack-launcher#feature/fixes", 74 | "karma-chai-sinon": "^0.1.5", 75 | "karma-chrome-launcher": "^0.2.0", 76 | "karma-cli": "0.1.2", 77 | "karma-coverage": "^0.5.3", 78 | "karma-mocha": "^0.2.2", 79 | "karma-mocha-reporter": "^1.2.2", 80 | "karma-safari-launcher": "^0.1.1", 81 | "karma-sauce-launcher": "^0.3.0", 82 | "karma-sourcemap-loader": "^0.3.5", 83 | "karma-tap-reporter": "0.0.6", 84 | "karma-webpack": "^1.7.0", 85 | "mocha": "^2.4.5", 86 | "path-to-regexp": "^1.2.1", 87 | "prettier": "^1.3.1", 88 | "prop-types": "^15.5.10", 89 | "react": "^15.0.1", 90 | "react-addons-test-utils": "^15.0.1", 91 | "react-dom": "^15.0.1", 92 | "react-redux": "^4.4.5", 93 | "react-router": "^2.0.0", 94 | "redux": "^3.3.1", 95 | "sinon": "^1.17.3", 96 | "sinon-chai": "^2.8.0", 97 | "webpack": "^1.12.14", 98 | "webpack-dev-server": "^1.12.0" 99 | }, 100 | "scripts": { 101 | "commit": "git-cz", 102 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 103 | "build": "npm run clean && npm run compile", 104 | "build:umd": "NODE_ENV=development webpack src/index.js dist/react-metrics.js", 105 | "build:umd:min": "NODE_ENV=production webpack -p src/index.js dist/react-metrics.min.js", 106 | "bundlesize": "npm run build:umd:min && bundlesize", 107 | "clean": "rimraf lib build", 108 | "compile": "babel src --out-dir lib", 109 | "examples": "webpack-dev-server --config examples/webpack.config.js --content-base examples --inline", 110 | "lint": "eslint src test examples --fix", 111 | "pretest": "npm run build", 112 | "prepublish": "npm run build && npm run build:umd && npm run build:umd:min", 113 | "test": "karma start" 114 | }, 115 | "config": { 116 | "commitizen": { 117 | "path": "./node_modules/cz-conventional-changelog" 118 | } 119 | }, 120 | "bundlesize": [ 121 | { 122 | "path": "./dist/react-metrics.min.js", 123 | "maxSize": "8.8 kB" 124 | } 125 | ], 126 | "registry": "npm" 127 | } 128 | -------------------------------------------------------------------------------- /examples/redux/basic/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp */ 2 | import React, {Component} from "react"; 3 | import PropTypes from "prop-types"; 4 | import ReactDOM from "react-dom"; 5 | import { 6 | Router, 7 | Route, 8 | IndexRoute, 9 | Link, 10 | IndexLink, 11 | useRouterHistory 12 | } from "react-router"; 13 | import createHistory from "history/lib/createHashHistory"; 14 | import {createStore, applyMiddleware} from "redux"; 15 | import {Provider, connect} from "react-redux"; 16 | import counter from "./counter"; 17 | import {inclement, declement, routeChange} from "./action"; 18 | import metricsMiddleware from "./metricsMiddleware"; 19 | 20 | const reducer = counter; 21 | const createStoreWithMiddleware = applyMiddleware(metricsMiddleware)( 22 | createStore 23 | ); 24 | const store = createStoreWithMiddleware(reducer, { 25 | counterA: 0, 26 | counterB: 0 27 | }); 28 | 29 | let prevLocation = {}; 30 | const history = createHistory(); 31 | history.listen(location => { 32 | if (location.pathname !== prevLocation.pathname) { 33 | store.dispatch(routeChange(location)); 34 | prevLocation = location; 35 | } 36 | }); 37 | const appHistory = useRouterHistory(createHistory)(); 38 | 39 | @connect(state => ({ 40 | counterA: state.counterA, 41 | counterB: state.counterB 42 | })) 43 | class Application extends Component { 44 | constructor(...args) { 45 | super(...args); 46 | 47 | this.onInclementClick = this.onInclementClick.bind(this); 48 | this.onDeclementClick = this.onDeclementClick.bind(this); 49 | } 50 | 51 | static propTypes = { 52 | children: PropTypes.node, 53 | dispatch: PropTypes.func.isRequired 54 | }; 55 | onInclementClick(id) { 56 | this.props.dispatch(inclement(id)); 57 | } 58 | onDeclementClick(id) { 59 | this.props.dispatch(declement(id)); 60 | } 61 | render() { 62 | return ( 63 |
    64 |
      65 |
    • Home
    • 66 |
    • Page A
    • 67 |
    • Page B
    • 68 |
    69 | {this.props.children && 70 | React.cloneElement(this.props.children, { 71 | ...this.props, 72 | onInclementClick: this.onInclementClick, 73 | onDeclementClick: this.onDeclementClick 74 | })} 75 |
    76 | ); 77 | } 78 | } 79 | 80 | class Home extends Component { 81 | render() { 82 | return

    Home

    ; 83 | } 84 | } 85 | 86 | class Page extends Component { 87 | static propTypes = { 88 | params: PropTypes.object, 89 | onInclementClick: PropTypes.func, 90 | onDeclementClick: PropTypes.func, 91 | counterA: PropTypes.number, 92 | counterB: PropTypes.number 93 | }; 94 | 95 | render() { 96 | const { 97 | params, 98 | counterA, 99 | counterB, 100 | onInclementClick, 101 | onDeclementClick 102 | } = this.props; 103 | return ( 104 |
    105 |

    Page {params.id}

    106 |
    counter A: {counterA}
    107 |
    counter B: {counterB}
    108 |
    109 | 112 | 115 |
    116 |
    117 | ); 118 | } 119 | } 120 | 121 | ReactDOM.render( 122 |
    123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
    , 132 | document.getElementById("example") 133 | ); 134 | -------------------------------------------------------------------------------- /karma.conf.sauce.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | module.exports = function (config) { 3 | // TODO: figure out Safari error where socket gets disconnected during the test. 4 | // https://saucelabs.com/platforms 5 | var customLaunchers = { 6 | SL_Chrome: { 7 | base: "SauceLabs", 8 | browserName: "chrome", 9 | version: "45" 10 | }, 11 | SL_Chrome_Latest: { 12 | base: "SauceLabs", 13 | browserName: "chrome", 14 | version: "46" 15 | }, 16 | SL_Firefox: { 17 | base: "SauceLabs", 18 | browserName: "firefox", 19 | version: "41" 20 | }, 21 | SL_Firefox_Latest: { 22 | base: "SauceLabs", 23 | browserName: "firefox", 24 | version: "42" 25 | }, 26 | SL_iOS8_Safari: { 27 | base: "SauceLabs", 28 | browserName: "iphone", 29 | platform: "OS X 10.10", 30 | version: "8.4" 31 | }, 32 | SL_iOS9_Safari: { 33 | base: "SauceLabs", 34 | browserName: "iphone", 35 | platform: "OS X 10.10", 36 | version: "9.1" 37 | }, 38 | SL_OSX_Safari8: { 39 | base: "SauceLabs", 40 | browserName: "safari", 41 | platform: "OS X 10.10", 42 | version: "8" 43 | }, 44 | SL_OSX_Safari9: { 45 | base: "SauceLabs", 46 | browserName: "safari", 47 | platform: "OS X 10.11", 48 | version: "9" 49 | }, 50 | SL_InternetExplorer10: { 51 | base: "SauceLabs", 52 | browserName: "internet explorer", 53 | platform: "Windows 8", 54 | version: "10" 55 | }, 56 | SL_InternetExplorer11: { 57 | base: "SauceLabs", 58 | browserName: "internet explorer", 59 | platform: "Windows 8.1", 60 | version: "11" 61 | }, 62 | SL_Edge: { 63 | base: "SauceLabs", 64 | browserName: "microsoftedge", 65 | platform: "Windows 10", 66 | version: "20" 67 | }, 68 | SL_ANDROID4: { 69 | base: "SauceLabs", 70 | browserName: "android", 71 | platform: "Linux", 72 | version: "4.4" 73 | }, 74 | SL_ANDROID5: { 75 | base: "SauceLabs", 76 | browserName: "android", 77 | platform: "Linux", 78 | version: "5.0" 79 | } 80 | }; 81 | 82 | // "saucelabs" reporter is necessary for their status badge to reflect the test result. 83 | config.set({ 84 | captureTimeout: 120000, 85 | browserNoActivityTimeout: 120000, 86 | browserDisconnectTimeout: 10000, 87 | browserDisconnectTolerance: 3, 88 | sauceLabs: { 89 | testName: "React Metrics", 90 | startConnect: true, 91 | recordVideo: false, 92 | recordScreenshots: false 93 | }, 94 | customLaunchers: customLaunchers, 95 | browsers: Object.keys(customLaunchers) 96 | }); 97 | 98 | if (process.env.DEBUG_SAUCE) { 99 | config.sauceLabs.connectOptions = { 100 | port: 5050, 101 | verbose: true, 102 | doctor: true 103 | }; 104 | } 105 | 106 | if (process.env.TRAVIS) { 107 | if (process.env.TRAVIS_PULL_REQUEST !== "false" || process.env.TRAVIS_BRANCH !== "master") { 108 | process.env.SAUCE_USERNAME = process.env.SAUCE_USERNAME_PR; 109 | process.env.SAUCE_ACCESS_KEY = process.env.SAUCE_ACCESS_KEY_PR; 110 | } 111 | console.log("SAUCE_USERNAME: ", process.env.SAUCE_USERNAME); 112 | // Sauce Connect through "karma-sauce-launcher" doesn"t work on Travis, manually run Sauce Connect 113 | config.sauceLabs.startConnect = false; 114 | config.sauceLabs.connectOptions = { 115 | port: 5050 116 | }; 117 | config.sauceLabs.build = "TRAVIS #" + process.env.TRAVIS_BUILD_NUMBER + " (" + process.env.TRAVIS_BUILD_ID + ")"; 118 | config.sauceLabs.tunnelIdentifier = process.env.TRAVIS_JOB_NUMBER; 119 | config.reporters = ["dots", "saucelabs"]; 120 | } 121 | }; 122 | -------------------------------------------------------------------------------- /docs/Guides.md: -------------------------------------------------------------------------------- 1 | ## [`location` Prop](#location) 2 | 3 | A location prop is a subset of [history](https://github.com/rackt/history)'s [Location](https://github.com/rackt/history/blob/master/docs/Location.md). 4 | 5 | ``` 6 | pathname The pathname portion of the URL, without query string 7 | search The query string portion of the URL, including the ? 8 | state An object of data tied to this location 9 | query A parsed object from query string 10 | ``` 11 | 12 | ## [`params`](#params) 13 | 14 | `params` is an object of key/value pairs that were parsed out of the route's dynamic segment definition when available. 15 | For example, when your routing library allows you to define one of the routes as `/user/:id`, and the actual `pathname` for the URL is `/user/123`, the `params` will be `{user: 123}`. 16 | 17 | ## [`routeState`](#routeState) 18 | 19 | `routeState` is an object which represents the current route state. It's essentially [`location`](#location) + [[`params`](#params)] and expected to change every time the URL changes. 20 | 21 | `react-metrics` will try to expose [`params`](#params) to `routeState` only when it's passed as props to metrics wrapper. 22 | 23 | ## [`vendor`](#vendor) 24 | 25 | A `vendor` option is an object member of `vendors` option array in configuration object and supports 2 properties: 26 | 27 | ### `name` 28 | 29 | A name of the vendor service to be returned as part of tracking response if defined. This will override the `name` property defined in `api`. 30 | 31 | ### `api` 32 | 33 | An object, a class instance or a function which returns an object which exposes tracking APIs. You don't have to define `pageView` or `track` api, but keep in mind that `react-metrics` will assume those methods to exist for auto page view and declarative tracking and throws when not available. 34 | You can define `name` property in your api, which will be returned as part of tracking response. 35 | 36 | Custom `api` methods can take 3 arguments: 37 | 38 | ``` 39 | someMethod(eventType?: string, eventData?: Object, shouldMergeWithDefaultObject?: boolean) 40 | ``` 41 | 42 | Example: 43 | 44 | ```javascript 45 | { 46 | vendors: [{ 47 | name: "Your Service Name Override", 48 | api: { 49 | name: "Your Service Name", 50 | pageView() { 51 | // your logic here 52 | }, 53 | track() { 54 | // your logic here 55 | }, 56 | someMethod() { 57 | // your logic here 58 | } 59 | } 60 | }] 61 | } 62 | 63 | ``` 64 | 65 | ## [Route Change Detection](#routeChangeDetection) 66 | 67 | `react-metrics` assumes that `metrics` wrapper receives [`location`](#location) props which is updated when the URL route changes to trigger page view call. 68 | 69 | Here are the implementation guides per use cases: 70 | 71 | | Routing Solution| Action required | Example | 72 | | ------------- | ------------- | ------------- | 73 | | [React Router](https://github.com/rackt/react-router) | Nothing! | [Here](/examples/react-router) | 74 | | Using [history](https://github.com/rackt/history) | Pass its [Location](https://github.com/rackt/history/blob/master/docs/Location.md) object to prop to metrics wrapper, optionally construct and pass [`params`](#params) prop | [Here](/examples/no-router-lib) | 75 | | Other solutions | Construct [`location`](#location) compliant prop and optionally [`params`](#params) prop to pass to metrics wrapper | [Here](/examples/cerebral) | 76 | 77 | **You can override this [logic](/src/react/getRouteState.js) by supplying `getRouteState` function as a [configuration option](/docs/api/ReactMetrics.md#config).** 78 | 79 | ## [How react-metrics detects your component as route handler?](#routeHandlerDetection) 80 | 81 | When you wrap your component with [`exposeMetrics`](/docs/api/ReactMetrics.md#exposeMetrics) to make `willTrackPageView` available, `react-metrics` will register your component in its registry. 82 | 83 | When [the route changes](#routeChangeDetection), it assumes the last registered component as the route handler component. A route handler component is the one which takes care of rendering the view for the corresponding route URL. 84 | 85 | For this assumption to work correctly, it's important to make sure your component gets mounted/unmounted as expected when a route changes. 86 | 87 | **You can override this [logic](/src/react/findRouteComponent.js) by supplying `findRouteComponent` function as a [configuration option](/docs/api/ReactMetrics.md#config).** 88 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # [2.4.1](https://github.com/nfl/react-metrics/compare/v2.4.0...v2.4.1) (2018-03-16) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * Fixes issue calling Custom Vendor API method with no arguments (#59) ([e8dbf85](https://github.com/nfl/react-metrics/commit/e8dbf85)) 8 | 9 | 10 | 11 | # [2.4.0](https://github.com/nfl/react-metrics/compare/v2.3.0...v2.4.0) (2017-11-14) 12 | 13 | 14 | ### Bug Fixes 15 | 16 | * Remove multiple instance check ([41b7bfa](https://github.com/nfl/react-metrics/commit/41b7bfa)) 17 | 18 | 19 | 20 | 21 | ## [2.3.2](https://github.com/nfl/react-metrics/compare/v2.3.1...v2.3.2) (2017-06-27) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * Fix for IE console log error with SVG parentNode (#46) ([51ff8a5](https://github.com/nfl/react-metrics/commit/51ff8a55bfa9c6dcdda9ab229d0ecf495961ef7c)) 27 | 28 | 29 | 30 | ## [2.3.1](https://github.com/nfl/react-metrics/compare/v2.3.0...v2.3.1) (2017-05-22) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * Fix warning when using PropTypes via main package (fix #39) (#43) ([03a4c4](https://github.com/nfl/react-metrics/commit/03a4c4d1b8025f635b35aaddea354982cf877805)) 36 | * Support ie10 quirks mode (#41) ([56ba30](https://github.com/nfl/react-metrics/commit/56ba305c4304ba3d8efbf8c8c0a99932d61734fd)) 37 | 38 | 39 | 40 | # [2.3.0](https://github.com/nfl/react-metrics/compare/v2.2.3...v2.3.0) (2017-02-23) 41 | 42 | 43 | ### Features 44 | 45 | * support aggregating metrics data within 'MetricsElement' ([dc976a3](https://github.com/nfl/react-metrics/commit/dc976a3)) 46 | 47 | 48 | 49 | 50 | ## [2.2.3](https://github.com/nfl/react-metrics/compare/v2.2.2...v2.2.3) (2016-10-31) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * Don't throw invariant error for missing pageView api when `enabled` is set to `false` in the co ([e820662](https://github.com/nfl/react-metrics/commit/e820662)) 56 | 57 | 58 | 59 | 60 | ## [2.2.2](https://github.com/nfl/react-metrics/compare/v2.2.1...v2.2.2) (2016-10-22) 61 | 62 | * Republish as 2.2.2 63 | 64 | 65 | ## [2.2.1](https://github.com/nfl/react-metrics/compare/v2.1.1...v2.2.1) (2016-10-18) 66 | 67 | 68 | ### Features 69 | 70 | * Allow merging pageDefaults data into declarative tracking data ([fd1c8bb](https://github.com/nfl/react-metrics/commit/fd1c8bb)) 71 | 72 | 73 | 74 | 75 | ## [2.1.1](https://github.com/nfl/react-metrics/compare/v2.1.0...v2.1.1) (2016-09-20) 76 | 77 | 78 | ### Performance Improvements 79 | 80 | * Prevent possible memory leaks on the server ([777a551](https://github.com/nfl/react-metrics/commit/777a551)) 81 | 82 | 83 | 84 | 85 | # [2.1.0](https://github.com/nfl/react-metrics/compare/v2.0.0...v2.1.0) (2016-08-09) 86 | 87 | 88 | ### Features 89 | 90 | * **MetricsElement:** add MetricsElement component ([b7c108e](https://github.com/nfl/react-metrics/commit/b7c108e)) 91 | 92 | 93 | 94 | 95 | # [2.0.0](https://github.com/nfl/react-metrics/compare/v1.2.1...v2.0.0) (2016-05-16) 96 | 97 | 98 | ### Code Refactoring 99 | 100 | * **build:** remove polyfill and add umd build([cabf6c3](https://github.com/nfl/react-metrics/commit/cabf6c3)) 101 | 102 | 103 | ### BREAKING CHANGES 104 | 105 | * build: remove polyfill and depend on the parent project to include the polyfill 106 | 107 | 108 | 109 | 110 | # [1.2.1](https://github.com/nfl/react-metrics/compare/1.1.1...v1.2.1) (2016-04-15) 111 | 112 | 113 | ### Features 114 | 115 | * **dependency:** Bump react to v15 ([d6ca28d](https://github.com/nfl/react-metrics/commit/d6ca28d)) 116 | 117 | 118 | 119 | 120 | 121 | ## [1.1.1](https://github.com/nfl/react-metrics/compare/1.1.0...v1.1.1) (2016-04-14) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * **package:** Fix react dependency ([aee0c8d](https://github.com/nfl/react-metrics/commit/aee0c8d)) 127 | 128 | 129 | 130 | 131 | # [1.1.0](https://github.com/nfl/react-metrics/compare/1.0.1...v1.1.0) (2016-02-29) 132 | 133 | 134 | ### Features 135 | 136 | * **babel:** Upgrade Babel to 6 ([10b4e90](https://github.com/nfl/react-metrics/commit/10b4e90)) 137 | 138 | 139 | 140 | 141 | 142 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 143 | 144 | - [1.0.1](#101) 145 | - [1.0.0](#100) 146 | 147 | 148 | 149 | ## 1.0.1 150 | 151 | Bugfixes: 152 | 153 | - Avoid invariant error when server caches modules 154 | - Fix typo in examples 155 | 156 | ## 1.0.0 157 | 158 | Features: 159 | 160 | - Initial release 161 | -------------------------------------------------------------------------------- /test/ReactMetrics/exposeMetrics.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, max-nested-callbacks, react/prop-types, no-empty, padded-blocks */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import createHistory from "history/lib/createMemoryHistory"; 5 | import {Router, Route} from "react-router"; 6 | import metrics from "../../src/react/metrics"; 7 | import exposeMetrics, { 8 | getMountedInstances 9 | } from "../../src/react/exposeMetrics"; 10 | import MetricsConfig from "../metrics.config"; 11 | 12 | describe("exposeMetrics", () => { 13 | let node; 14 | 15 | beforeEach(() => { 16 | node = document.createElement("div"); 17 | MetricsConfig.autoTrackPageView = false; 18 | }); 19 | 20 | afterEach(() => { 21 | try { 22 | ReactDOM.unmountComponentAtNode(node); 23 | } catch (err) {} 24 | MetricsConfig.autoTrackPageView = true; 25 | }); 26 | 27 | it("should be named after wrapped component", () => { 28 | class Comp1 extends React.Component { 29 | static displayName = "Compo1"; 30 | render() { 31 | return

    Page

    ; 32 | } 33 | } 34 | 35 | let Metrics = exposeMetrics(Comp1); 36 | 37 | expect(Metrics.displayName).to.be.equal("Metrics(Compo1)"); 38 | 39 | class Comp2 extends React.Component { 40 | render() { 41 | return

    Page

    ; 42 | } 43 | } 44 | 45 | Metrics = exposeMetrics(Comp2); 46 | 47 | expect(Metrics.displayName).to.be.equal("Metrics(Comp2)"); 48 | 49 | class Comp3 extends React.Component { 50 | render() { 51 | return

    Page

    ; 52 | } 53 | } 54 | 55 | Metrics = exposeMetrics(Comp3); 56 | 57 | expect(Metrics.displayName).to.be.equal("Metrics(Comp3)"); 58 | 59 | class Comp4 extends React.Component { 60 | static displayName = null; 61 | 62 | render() { 63 | return

    Page

    ; 64 | } 65 | } 66 | 67 | Metrics = exposeMetrics(Comp4); 68 | 69 | expect(Metrics.displayName).to.contains("Metrics("); 70 | }); 71 | 72 | it("should provide 'willTrackPageView' static method to route handler component", done => { 73 | @metrics(MetricsConfig) 74 | class Application extends React.Component { 75 | render() { 76 | return
    {this.props.children}
    ; 77 | } 78 | } 79 | 80 | @exposeMetrics class Page extends React.Component { 81 | static displayName = "Page"; 82 | 83 | static willTrackPageView() { 84 | expect(true).to.be.ok; 85 | done(); 86 | } 87 | 88 | render() { 89 | return

    Page

    ; 90 | } 91 | } 92 | 93 | ReactDOM.render( 94 | 95 | 96 | 97 | 98 | , 99 | node, 100 | function() { 101 | this.history.pushState(null, "/page/1"); 102 | } 103 | ); 104 | }); 105 | 106 | it("should support partial application", done => { 107 | @metrics(MetricsConfig) 108 | class Application extends React.Component { 109 | render() { 110 | return
    {this.props.children}
    ; 111 | } 112 | } 113 | 114 | @exposeMetrics() 115 | class Page extends React.Component { 116 | static willTrackPageView() { 117 | expect(true).to.be.ok; 118 | done(); 119 | } 120 | 121 | render() { 122 | return

    Page

    ; 123 | } 124 | } 125 | 126 | ReactDOM.render( 127 | 128 | 129 | 130 | 131 | , 132 | node, 133 | function() { 134 | this.history.pushState(null, "/page/1"); 135 | } 136 | ); 137 | }); 138 | 139 | it("should register itself to a registry when mounting, unregister itself from a registry when unmounting", done => { 140 | @exposeMetrics class Application extends React.Component { 141 | render() { 142 | return
    Application
    ; 143 | } 144 | } 145 | 146 | ReactDOM.render(, node, () => { 147 | const registry = getMountedInstances(); 148 | expect(registry).to.have.length(1); 149 | ReactDOM.unmountComponentAtNode(node); 150 | expect(registry).to.have.length(0); 151 | done(); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/core/attr2obj.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp,jsx-a11y/href-no-hash */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import TestUtils from "react-addons-test-utils"; 5 | import attr2obj from "../../src/core/utils/attr2obj"; 6 | 7 | describe("attr2obj", () => { 8 | it("returns empty object if an element is not nodeType 1", () => { 9 | const node = document.createDocumentFragment(); 10 | const obj = attr2obj(node); 11 | expect(obj).to.be.an("object"); 12 | expect(Object.keys(obj).length).to.equal(0); 13 | }); 14 | 15 | it("extracts prefixed attributes as empty object when no attribute is found", () => { 16 | const node = document.createElement("a"); 17 | const obj = attr2obj(node, "prefix"); 18 | expect(obj).to.be.an("object"); 19 | expect(Object.keys(obj).length).to.equal(0); 20 | }); 21 | 22 | it("uses 'data' as default prefix", () => { 23 | const node = document.createElement("a"); 24 | node.setAttribute("data-event", "My Event Name"); 25 | const obj = attr2obj(node); 26 | expect(obj).to.be.an("object"); 27 | expect(obj).to.have.property("event").and.to.equal("My Event Name"); 28 | }); 29 | 30 | it("extracts prefixed attributes as object", () => { 31 | let node = document.createElement("a"); 32 | let obj; 33 | node.setAttribute("data-analytics-event", "My Event Name"); 34 | node.setAttribute("data-analytics-prop", "value"); 35 | node.setAttribute("data-analytics-camel-case", "value 2"); 36 | node.setAttribute("data-analytics-another-camel-case", "value 3"); 37 | obj = attr2obj(node, "data-analytics"); 38 | 39 | expect(obj).to.be.an("object"); 40 | expect(obj).to.have.property("event").and.to.equal("My Event Name"); 41 | expect(obj).to.have.property("prop").and.to.equal("value"); 42 | expect(obj).to.have.property("camelCase").and.to.equal("value 2"); 43 | expect(obj).to.have 44 | .property("anotherCamelCase") 45 | .and.to.equal("value 3"); 46 | 47 | class Wrapper extends React.Component { 48 | render() { 49 | return ( 50 | 57 | ); 58 | } 59 | } 60 | 61 | const element = TestUtils.renderIntoDocument(); 62 | node = ReactDOM.findDOMNode( 63 | TestUtils.findRenderedDOMComponentWithTag(element, "a") 64 | ); 65 | obj = attr2obj(node, "data-analytics"); 66 | 67 | expect(obj).to.be.an("object"); 68 | expect(obj).to.have.property("event").and.to.equal("My Event Name"); 69 | expect(obj).to.have.property("prop").and.to.equal("value"); 70 | expect(obj).to.have.property("camelCase").and.to.equal("value 2"); 71 | expect(obj).to.have 72 | .property("anotherCamelCase") 73 | .and.to.equal("value 3"); 74 | }); 75 | 76 | it("extracts custom prefixed attributes as object", () => { 77 | let node = document.createElement("a"); 78 | let obj; 79 | node.setAttribute("custom-event", "My Event Name"); 80 | node.setAttribute("custom-prop", "value"); 81 | node.setAttribute("custom-camel-case", "value 2"); 82 | node.setAttribute("custom-another-camel-case", "value 3"); 83 | obj = attr2obj(node, "custom"); 84 | 85 | expect(obj).to.be.an("object"); 86 | expect(obj).to.have.property("event").and.to.equal("My Event Name"); 87 | expect(obj).to.have.property("prop").and.to.equal("value"); 88 | expect(obj).to.have.property("camelCase").and.to.equal("value 2"); 89 | expect(obj).to.have 90 | .property("anotherCamelCase") 91 | .and.to.equal("value 3"); 92 | 93 | class Wrapper extends React.Component { 94 | render() { 95 | return ( 96 | 103 | ); 104 | } 105 | } 106 | 107 | const element = TestUtils.renderIntoDocument(); 108 | 109 | node = ReactDOM.findDOMNode( 110 | TestUtils.findRenderedDOMComponentWithTag(element, "a") 111 | ); 112 | obj = attr2obj(node, "data-analytics"); 113 | 114 | // React will remove non-HTML spec attributes 115 | expect(obj).to.be.an("object"); 116 | expect(Object.keys(obj).length).to.equal(0); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/core/extractApis.spec.js: -------------------------------------------------------------------------------- 1 | import extractApis, {filterKeysByType} from "../../src/core/utils/extractApis"; 2 | 3 | describe("extractApis", () => { 4 | it("extracts method names from object", () => { 5 | const service = { 6 | methodA() {}, 7 | methodB() {} 8 | }; 9 | const result = extractApis(service); 10 | expect(result).to.be.an("array"); 11 | expect(result).to.have.length(2); 12 | expect(result).to.have.members(["methodA", "methodB"]); 13 | }); 14 | 15 | it("extracts method names from extended object", () => { 16 | const parent = { 17 | methodA() {} 18 | }; 19 | const service = Object.create(parent, { 20 | methodB: {value() {}} 21 | }); 22 | const result = extractApis(service); 23 | expect(result).to.be.an("array"); 24 | expect(result).to.have.length(2); 25 | expect(result).to.have.members(["methodA", "methodB"]); 26 | }); 27 | 28 | it("extracts method names from class instance", () => { 29 | class ServiceClass { 30 | methodA() {} 31 | methodB() {} 32 | } 33 | const service = new ServiceClass(); 34 | const result = extractApis(service); 35 | expect(result).to.be.an("array"); 36 | expect(result).to.have.length(2); 37 | expect(result).to.have.members(["methodA", "methodB"]); 38 | }); 39 | 40 | it("extracts method names from inherited class instance", () => { 41 | class ParentClass { 42 | methodA() {} 43 | } 44 | class ServiceClass extends ParentClass { 45 | methodB() {} 46 | } 47 | const service = new ServiceClass(); 48 | const result = extractApis(service); 49 | expect(result).to.be.an("array"); 50 | expect(result).to.have.length(2); 51 | expect(result).to.have.members(["methodA", "methodB"]); 52 | }); 53 | 54 | it("extracts method names from prototypal class instance", () => { 55 | function ServiceClass() {} 56 | ServiceClass.prototype = { 57 | methodA() {}, 58 | methodB() {} 59 | }; 60 | const service = new ServiceClass(); 61 | const result = extractApis(service); 62 | expect(result).to.be.an("array"); 63 | expect(result).to.have.length(2); 64 | expect(result).to.have.members(["methodA", "methodB"]); 65 | }); 66 | 67 | it("extracts method names from inherited prototypal class instance", () => { 68 | function ParentClass() {} 69 | ParentClass.prototype = { 70 | methodA() {} 71 | }; 72 | function ServiceClass() {} 73 | ServiceClass.prototype = new ParentClass(); 74 | ServiceClass.prototype.methodB = function() {}; 75 | const service = new ServiceClass(); 76 | const result = extractApis(service); 77 | expect(result).to.be.an("array"); 78 | expect(result).to.have.length(2); 79 | expect(result).to.have.members(["methodA", "methodB"]); 80 | }); 81 | 82 | it("excludes method starting with '_'", () => { 83 | const service = { 84 | methodA() {}, 85 | _methodB() {} 86 | }; 87 | const result = extractApis(service); 88 | expect(result).to.be.an("array"); 89 | expect(result).to.have.length(1); 90 | expect(result).to.have.members(["methodA"]); 91 | expect(result).to.not.have.members(["_methodB"]); 92 | }); 93 | 94 | it("extracts method names from an array of mixed object", () => { 95 | const service1 = { 96 | methodA() {}, 97 | methodB() {}, 98 | methodC() {} 99 | }; 100 | class ServiceClass { 101 | methodB() {} 102 | methodC() {} 103 | methodD() {} 104 | } 105 | const service2 = new ServiceClass(); 106 | const result = extractApis([service1, service2]); 107 | expect(result).to.be.an("array"); 108 | expect(result).to.have.length(4); 109 | expect(result).to.have.members([ 110 | "methodA", 111 | "methodB", 112 | "methodC", 113 | "methodD" 114 | ]); 115 | }); 116 | 117 | it("uses total and type for filterKeysByType", () => { 118 | let result = filterKeysByType({ 119 | methodA() {}, 120 | methodB() {} 121 | }); 122 | expect(result).to.be.an("array"); 123 | expect(result).to.have.length(2); 124 | expect(result).to.have.members(["methodA", "methodB"]); 125 | 126 | result = filterKeysByType( 127 | { 128 | methodA() {}, 129 | methodB() {}, 130 | methodC() {} 131 | }, 132 | ["methodB"] 133 | ); 134 | expect(result).to.be.an("array"); 135 | expect(result).to.have.length(2); 136 | expect(result).to.have.members(["methodA", "methodC"]); 137 | 138 | result = filterKeysByType( 139 | { 140 | methodA: "methodA", 141 | methodB: "methodB" 142 | }, 143 | [], 144 | "string" 145 | ); 146 | expect(result).to.be.an("array"); 147 | expect(result).to.have.length(2); 148 | expect(result).to.have.members(["methodA", "methodB"]); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/core/createService.spec.js: -------------------------------------------------------------------------------- 1 | import createService from "../../src/core/createService"; 2 | 3 | describe("createService", () => { 4 | it("provides default 'pageView' and 'track'", () => { 5 | let service = createService(); 6 | expect(service).to.have.property("name", undefined); 7 | expect(service.apis.pageView).to.exist; 8 | expect(service.apis.track).to.exist; 9 | expect(() => { 10 | service.apis.pageView(); 11 | service.apis.track(); 12 | }).to.not.throw(); 13 | 14 | const options = { 15 | api() {} 16 | }; 17 | service = createService(options); 18 | expect(service).to.have.property("name", undefined); 19 | expect(service.apis.pageView).to.exist; 20 | expect(service.apis.track).to.exist; 21 | }); 22 | 23 | it("does not require 'pageView' and 'track' to be defined", () => { 24 | const options = { 25 | name: "Test", 26 | api: { 27 | methodA() {} 28 | } 29 | }; 30 | const service = createService(options); 31 | expect(service).to.have.property("name", options.name); 32 | expect(service.apis.methodA).to.exist; 33 | expect(service.apis.pageView).to.not.exist; 34 | expect(service.apis.track).to.not.exist; 35 | }); 36 | 37 | it("can handle object", () => { 38 | const options = { 39 | name: "Test", 40 | api: { 41 | pageView() {}, 42 | track() {}, 43 | methodA() {} 44 | } 45 | }; 46 | const service = createService(options); 47 | expect(service).to.have.property("name", options.name); 48 | expect(service.apis.pageView).to.exist; 49 | expect(service.apis.track).to.exist; 50 | expect(service.apis.methodA).to.exist; 51 | }); 52 | 53 | it("can handle extended object", () => { 54 | const parent = { 55 | pageView() {} 56 | }; 57 | const api = Object.create(parent, { 58 | track: {value() {}}, 59 | methodA: {value() {}} 60 | }); 61 | const options = { 62 | name: "Test", 63 | api 64 | }; 65 | const service = createService(options); 66 | expect(service).to.have.property("name", options.name); 67 | expect(service.apis.pageView).to.exist; 68 | expect(service.apis.track).to.exist; 69 | expect(service.apis.methodA).to.exist; 70 | }); 71 | 72 | it("can handle class", () => { 73 | class ServiceClass { 74 | pageView() {} 75 | track() {} 76 | methodA() {} 77 | } 78 | const options = { 79 | name: "Test", 80 | api: ServiceClass 81 | }; 82 | const service = createService(options); 83 | expect(service).to.have.property("name", options.name); 84 | expect(service.apis.pageView).to.exist; 85 | expect(service.apis.track).to.exist; 86 | expect(service.apis.methodA).to.exist; 87 | }); 88 | 89 | it("can handle class instance", () => { 90 | class ServiceClass { 91 | pageView() {} 92 | track() {} 93 | methodA() {} 94 | } 95 | const options = { 96 | name: "Test", 97 | api: new ServiceClass() 98 | }; 99 | const service = createService(options); 100 | expect(service).to.have.property("name", options.name); 101 | expect(service.apis.pageView).to.exist; 102 | expect(service.apis.track).to.exist; 103 | expect(service.apis.methodA).to.exist; 104 | }); 105 | 106 | it("can handle extended class", () => { 107 | class ParentClass { 108 | pageView() {} 109 | track() {} 110 | methodA() {} 111 | } 112 | class ServiceClass extends ParentClass {} 113 | const options = { 114 | name: "Test", 115 | api: ServiceClass 116 | }; 117 | const service = createService(options); 118 | expect(service).to.have.property("name", options.name); 119 | expect(service.apis.pageView).to.exist; 120 | expect(service.apis.track).to.exist; 121 | expect(service.apis.methodA).to.exist; 122 | }); 123 | 124 | it("can handle function", () => { 125 | function getService() { 126 | return { 127 | pageView() {}, 128 | track() {}, 129 | methodA() {} 130 | }; 131 | } 132 | const options = { 133 | name: "Test", 134 | api: getService 135 | }; 136 | const service = createService(options); 137 | expect(service).to.have.property("name", options.name); 138 | expect(service.apis.pageView).to.exist; 139 | expect(service.apis.track).to.exist; 140 | expect(service.apis.methodA).to.exist; 141 | }); 142 | 143 | it("can override 'name' property from options", () => { 144 | class ServiceClass { 145 | constructor() { 146 | this.name = "Test"; 147 | } 148 | pageView() {} 149 | track() {} 150 | methodA() {} 151 | } 152 | const options = { 153 | api: ServiceClass 154 | }; 155 | const options2 = { 156 | name: "Test Override", 157 | api: ServiceClass 158 | }; 159 | const service = createService(options); 160 | const service2 = createService(options2); 161 | expect(service).to.have.property("name", "Test"); 162 | expect(service2).to.have.property("name", "Test Override"); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/react/metrics.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import PropTypes from "prop-types"; 3 | import ReactDOM from "react-dom"; 4 | import invariant from "fbjs/lib/invariant"; 5 | import {canUseDOM} from "fbjs/lib/ExecutionEnvironment"; 6 | import {metrics as metricsType, location as locationType} from "./PropTypes"; 7 | import createMetrics, {isMetrics} from "../core/createMetrics"; 8 | import getRouteState from "./getRouteState"; 9 | import findRouteComponent from "./findRouteComponent"; 10 | import hoistStatics from "hoist-non-react-statics"; 11 | 12 | function getDisplayName(Comp) { 13 | return Comp.displayName || Comp.name || "Component"; 14 | } 15 | 16 | let mountedInstances; 17 | 18 | export default function metrics(metricsOrConfig, options = {}) { 19 | const autoTrackPageView = options.autoTrackPageView !== false; 20 | const useTrackBinding = options.useTrackBinding !== false; 21 | const attributePrefix = options.attributePrefix; 22 | const suppressTrackBindingWarning = !!options.suppressTrackBindingWarning; 23 | const getNewRouteState = options.getRouteState || getRouteState; 24 | const findNewRouteComponent = 25 | options.findRouteComponent || findRouteComponent; 26 | const metricsInstance = isMetrics(metricsOrConfig) 27 | ? metricsOrConfig 28 | : createMetrics(metricsOrConfig); 29 | 30 | return function wrap(ComposedComponent) { 31 | class MetricsContainer extends Component { 32 | static displayName = "MetricsContainer"; 33 | 34 | static childContextTypes = { 35 | metrics: metricsType.isRequired, 36 | _metricsConfig: PropTypes.object 37 | }; 38 | 39 | static propTypes = { 40 | location: locationType, 41 | params: PropTypes.object 42 | }; 43 | 44 | static getMountedMetricsInstances() { 45 | // eslint-disable-line react/sort-comp 46 | if (!mountedInstances) { 47 | mountedInstances = []; 48 | } 49 | return mountedInstances; 50 | } 51 | 52 | componentWillMount() { 53 | if (!canUseDOM) { 54 | return; 55 | } 56 | 57 | const instances = this.constructor.getMountedMetricsInstances(); 58 | instances.push(ComposedComponent); 59 | 60 | this._newRouteState = getNewRouteState(this.props); 61 | if (this._newRouteState) { 62 | this._getMetrics().setRouteState(this._newRouteState); 63 | } 64 | } 65 | componentDidMount() { 66 | if (useTrackBinding) { 67 | const rootElement = ReactDOM.findDOMNode(this); 68 | // TODO: is this invariant check still valid after react >= 0.14.0? 69 | invariant( 70 | rootElement, 71 | "`metrics` should be added to the root most component which renders node element for declarative tracking to work." 72 | ); 73 | this._getMetrics().useTrackBinding( 74 | rootElement, 75 | attributePrefix 76 | ); 77 | } 78 | 79 | if (this._newRouteState) { 80 | this._handleRouteStateChange(this._newRouteState); 81 | this._newRouteState = null; 82 | } 83 | } 84 | componentWillReceiveProps(newProps) { 85 | this._newRouteState = getNewRouteState(newProps, this.props); 86 | if (this._newRouteState) { 87 | this._getMetrics().setRouteState(this._newRouteState); 88 | } 89 | } 90 | componentDidUpdate() { 91 | if (this._newRouteState) { 92 | this._handleRouteStateChange(this._newRouteState); 93 | this._newRouteState = null; 94 | } 95 | } 96 | componentWillUnmount() { 97 | const instances = this.constructor.getMountedMetricsInstances(); 98 | const index = instances.indexOf(ComposedComponent); 99 | instances.splice(index, 1); 100 | 101 | this._getMetrics().destroy(); 102 | } 103 | 104 | getChildContext() { 105 | return { 106 | metrics: this._getMetrics().api, 107 | _metricsConfig: { 108 | autoTrackPageView, 109 | useTrackBinding, 110 | attributePrefix, 111 | suppressTrackBindingWarning, 112 | getNewRouteState, 113 | findNewRouteComponent 114 | } 115 | }; 116 | } 117 | 118 | _getMetrics() { 119 | return metricsInstance; 120 | } 121 | /** 122 | * Triggered when the route changes and fires page view tracking. 123 | * 124 | * @method _handleRouteStateChange 125 | * @param {Object} props 126 | * @private 127 | */ 128 | _handleRouteStateChange(routeState) { 129 | const component = findNewRouteComponent(); 130 | const metricsInst = this._getMetrics(); 131 | let pageViewParams; 132 | let shouldSuppress = false; 133 | 134 | if (component) { 135 | const ret = 136 | component.willTrackPageView && 137 | component.willTrackPageView(routeState); 138 | if (ret === false) { 139 | shouldSuppress = true; 140 | } else if (ret) { 141 | pageViewParams = ret; 142 | } 143 | } 144 | 145 | if ( 146 | metricsInst.enabled && 147 | autoTrackPageView && 148 | !shouldSuppress 149 | ) { 150 | invariant( 151 | typeof metricsInst.api.pageView === "function", 152 | "react-metrics: 'pageView' api needs to be defined for automatic page view tracking." 153 | ); 154 | metricsInst.api.pageView(pageViewParams); 155 | } 156 | } 157 | /** 158 | * Renders composed component. 159 | * 160 | * @method render 161 | * @returns {ReactElement} 162 | */ 163 | render() { 164 | return ( 165 | 169 | ); 170 | } 171 | } 172 | 173 | return hoistStatics(MetricsContainer, ComposedComponent); 174 | }; 175 | } 176 | -------------------------------------------------------------------------------- /test/ReactMetrics/willTrackPageView.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, max-nested-callbacks, react/prop-types, padded-blocks */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import createHistory from "history/lib/createMemoryHistory"; 5 | import {Router, Route} from "react-router"; 6 | import metrics from "../../src/react/metrics"; 7 | import exposeMetrics from "../../src/react/exposeMetrics"; 8 | import MetricsConfig from "../metrics.config"; 9 | import metricsMock from "../metricsMock"; 10 | 11 | describe("willTrackPageView", () => { 12 | let node; 13 | 14 | beforeEach(() => { 15 | node = document.createElement("div"); 16 | }); 17 | 18 | afterEach(() => { 19 | ReactDOM.unmountComponentAtNode(node); 20 | }); 21 | 22 | it("is called after 'componentDidMount' and 'componentDidUpdate'", done => { 23 | let componentWillMountCalled = false; 24 | let componentDidMountCalled = false; 25 | let componentWillReceivePropsCalled = false; 26 | let componentDidUpdateCalled = false; 27 | let willTrackPageViewCount = 0; 28 | 29 | @metrics(MetricsConfig) 30 | class Application extends React.Component { 31 | static displayName = "Application"; 32 | 33 | render() { 34 | return

    Application

    {this.props.children}
    ; 35 | } 36 | } 37 | 38 | class Page extends React.Component { 39 | static displayName = "Page"; 40 | 41 | render() { 42 | return

    Page

    {this.props.children}
    ; 43 | } 44 | } 45 | 46 | @exposeMetrics class Content extends React.Component { 47 | static displayName = "Content"; 48 | 49 | static willTrackPageView() { 50 | if (willTrackPageViewCount === 0) { 51 | expect(componentWillMountCalled).to.equal(true); 52 | expect(componentDidMountCalled).to.equal(true); 53 | } else if (willTrackPageViewCount === 1) { 54 | expect(componentWillReceivePropsCalled).to.equal(true); 55 | expect(componentDidUpdateCalled).to.equal(true); 56 | done(); 57 | } 58 | willTrackPageViewCount++; 59 | } 60 | 61 | componentWillMount() { 62 | componentWillMountCalled = true; 63 | } 64 | 65 | componentDidMount() { 66 | componentDidMountCalled = true; 67 | } 68 | 69 | componentWillReceiveProps() { 70 | componentWillReceivePropsCalled = true; 71 | } 72 | 73 | componentDidUpdate() { 74 | componentDidUpdateCalled = true; 75 | } 76 | 77 | render() { 78 | return

    Content

    ; 79 | } 80 | } 81 | 82 | ReactDOM.render( 83 | 84 | 85 | 86 | 87 | 88 | 89 | , 90 | node, 91 | function() { 92 | this.history.pushState(null, "/page/content2"); 93 | } 94 | ); 95 | }); 96 | 97 | it("cancels page view tracking when returns 'false'.", done => { 98 | @metrics(MetricsConfig) 99 | @exposeMetrics 100 | class Application extends React.Component { 101 | static displayName = "Application"; 102 | 103 | static willTrackPageView() { 104 | return false; 105 | } 106 | 107 | render() { 108 | return
    {this.props.children}
    ; 109 | } 110 | } 111 | 112 | const mock = sinon.mock(metricsMock.api); 113 | const pageView = sinon.stub( 114 | Application.prototype, 115 | "_getMetrics", 116 | () => { 117 | return metricsMock; 118 | } 119 | ); 120 | mock.expects("pageView").never(); 121 | 122 | ReactDOM.render( 123 | 124 | 125 | , 126 | node, 127 | () => { 128 | mock.verify(); 129 | mock.restore(); 130 | pageView.restore(); 131 | done(); 132 | } 133 | ); 134 | }); 135 | 136 | it("can accpets object", done => { 137 | @metrics(MetricsConfig) 138 | @exposeMetrics 139 | class Application extends React.Component { 140 | static displayName = "Application"; 141 | 142 | static willTrackPageView() { 143 | return { 144 | prop1: "value1" 145 | }; 146 | } 147 | 148 | render() { 149 | return
    {this.props.children}
    ; 150 | } 151 | } 152 | 153 | const pageView = sinon.stub( 154 | Application.prototype, 155 | "_getMetrics", 156 | () => { 157 | return { 158 | ...metricsMock, 159 | api: { 160 | pageView(...args) { 161 | expect(typeof args[0]).to.be.equal("object"); 162 | pageView.restore(); 163 | done(); 164 | } 165 | } 166 | }; 167 | } 168 | ); 169 | 170 | ReactDOM.render( 171 | 172 | 173 | , 174 | node 175 | ); 176 | }); 177 | 178 | it("receives 'routeState' object with expected props and values", done => { 179 | const state = {isModal: false}; 180 | 181 | @metrics(MetricsConfig) 182 | class Application extends React.Component { 183 | static displayName = "Application"; 184 | 185 | render() { 186 | return
    {this.props.children}
    ; 187 | } 188 | } 189 | 190 | @exposeMetrics class Page extends React.Component { 191 | static displayName = "Page"; 192 | 193 | static willTrackPageView(routeState) { 194 | expect(routeState.pathname).to.equal("/page/123"); 195 | expect(routeState.search).to.equal("?param1=value1"); 196 | expect(routeState.hash).to.equal(""); 197 | expect(JSON.stringify(routeState.state)).to.equal( 198 | JSON.stringify(state) 199 | ); 200 | expect(JSON.stringify(routeState.params)).to.equal( 201 | JSON.stringify({id: "123"}) 202 | ); 203 | done(); 204 | return true; 205 | } 206 | 207 | render() { 208 | return

    Page

    {this.props.children}
    ; 209 | } 210 | } 211 | 212 | ReactDOM.render( 213 | 214 | 215 | 216 | 217 | , 218 | node, 219 | function() { 220 | this.history.pushState(state, "/page/123?param1=value1"); 221 | } 222 | ); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /test/ReactMetrics/MetricsElement.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, max-nested-callbacks, react/prop-types, no-empty, padded-blocks */ 2 | import React from "react"; 3 | import PropTypes from "prop-types"; 4 | import ReactDOM from "react-dom"; 5 | import metrics from "../../src/react/metrics"; 6 | import MetricsElement from "../../src/react/MetricsElement"; 7 | import MetricsConfig from "../metrics.config"; 8 | import metricsMock from "../metricsMock"; 9 | 10 | describe("MetricsElement", () => { 11 | let node; 12 | 13 | // element.click() doesn't work on Travis Chrome 14 | function simulateClick(metricsElement, target) { 15 | const callback = metricsElement._handleClick.bind(metricsElement); 16 | metricsElement._trackBindingListener.target._handleClick(callback, { 17 | target, 18 | button: 0 19 | }); 20 | } 21 | 22 | beforeEach(() => { 23 | node = document.createElement("div"); 24 | MetricsConfig.autoTrackPageView = false; 25 | }); 26 | 27 | afterEach(() => { 28 | try { 29 | ReactDOM.unmountComponentAtNode(node); 30 | } catch (err) {} 31 | }); 32 | 33 | it("throws when used w/o metrics HOC", () => { 34 | class Application extends React.Component { 35 | componentDidMount() { 36 | simulateClick(this.refs.metricsElement, this.refs.img); 37 | } 38 | render() { 39 | return ( 40 | 41 |
    45 | 50 | 51 | 52 | ); 53 | } 54 | } 55 | expect(() => { 56 | ReactDOM.render(, node); 57 | }).to.throw( 58 | "MetricsElement requires metrics HOC to exist in the parent tree." 59 | ); 60 | }); 61 | 62 | it("should send tracking as empty wrapper", done => { 63 | @metrics(MetricsConfig) 64 | class Application extends React.Component { 65 | componentDidMount() { 66 | simulateClick(this.refs.metricsElement, this.refs.img); 67 | } 68 | render() { 69 | return ( 70 | 71 | 75 | 80 | 81 | 82 | ); 83 | } 84 | } 85 | 86 | const track = sinon.stub( 87 | metricsMock.api, 88 | "track", 89 | (eventName, params) => { 90 | expect(eventName).to.equal("SomeEvent"); 91 | expect(params).to.eql({value: "SomeVavlue"}); 92 | track.restore(); 93 | done(); 94 | } 95 | ); 96 | sinon.stub(Application.prototype, "_getMetrics", () => { 97 | return metricsMock; 98 | }); 99 | 100 | ReactDOM.render(, node); 101 | }); 102 | 103 | it("should send tracking as an html element", done => { 104 | @metrics(MetricsConfig) 105 | class Application extends React.Component { 106 | componentDidMount() { 107 | simulateClick(this.refs.metricsElement, this.refs.img); 108 | } 109 | render() { 110 | return ( 111 | 117 | 118 | 123 | 124 | 125 | ); 126 | } 127 | } 128 | 129 | const track = sinon.stub( 130 | metricsMock.api, 131 | "track", 132 | (eventName, params) => { 133 | expect(eventName).to.equal("SomeEvent"); 134 | expect(params).to.eql({value: "SomeVavlue"}); 135 | track.restore(); 136 | done(); 137 | } 138 | ); 139 | sinon.stub(Application.prototype, "_getMetrics", () => { 140 | return metricsMock; 141 | }); 142 | 143 | ReactDOM.render(, node); 144 | }); 145 | 146 | it("should send tracking as a component", done => { 147 | class Comp extends React.Component { 148 | static propTypes = { 149 | children: PropTypes.node 150 | }; 151 | render() { 152 | return ( 153 |
    154 | {React.cloneElement(this.props.children, { 155 | ...this.props 156 | })} 157 |
    158 | ); 159 | } 160 | } 161 | 162 | @metrics(MetricsConfig) 163 | class Application extends React.Component { 164 | componentDidMount() { 165 | simulateClick(this.refs.metricsElement, this.refs.img); 166 | } 167 | render() { 168 | return ( 169 | 175 | 176 | 181 | 182 | 183 | ); 184 | } 185 | } 186 | 187 | const track = sinon.stub( 188 | metricsMock.api, 189 | "track", 190 | (eventName, params) => { 191 | expect(eventName).to.equal("SomeEvent"); 192 | expect(params).to.eql({value: "SomeVavlue"}); 193 | track.restore(); 194 | done(); 195 | } 196 | ); 197 | sinon.stub(Application.prototype, "_getMetrics", () => { 198 | return metricsMock; 199 | }); 200 | 201 | ReactDOM.render(, node); 202 | }); 203 | 204 | it("should send tracking as a component instance", done => { 205 | class Comp extends React.Component { 206 | static propTypes = { 207 | children: PropTypes.node 208 | }; 209 | render() { 210 | return ( 211 |
    212 | {React.cloneElement(this.props.children, { 213 | ...this.props 214 | })} 215 |
    216 | ); 217 | } 218 | } 219 | 220 | @metrics(MetricsConfig) 221 | class Application extends React.Component { 222 | componentDidMount() { 223 | simulateClick(this.refs.metricsElement, this.refs.img); 224 | } 225 | render() { 226 | const comp = ( 227 | 231 | 232 | 237 | 238 | 239 | ); 240 | return ; 241 | } 242 | } 243 | 244 | const track = sinon.stub( 245 | metricsMock.api, 246 | "track", 247 | (eventName, params) => { 248 | expect(eventName).to.equal("SomeEvent"); 249 | expect(params).to.eql({value: "SomeVavlue"}); 250 | track.restore(); 251 | done(); 252 | } 253 | ); 254 | sinon.stub(Application.prototype, "_getMetrics", () => { 255 | return metricsMock; 256 | }); 257 | 258 | ReactDOM.render(, node); 259 | }); 260 | }); 261 | -------------------------------------------------------------------------------- /docs/api/ReactMetrics.md: -------------------------------------------------------------------------------- 1 | ## React Metrics API References 2 | 3 | ### [`metrics(config, [options])(Component)`](#metrics) 4 | 5 | Wraps your root level component and returns wrapped component which exposes `metrics` context where your child component can access APIs defined in your configuration. 6 | 7 | #### Usage 8 | 9 | ES6 10 | 11 | ``` 12 | class Application extends React.Component { 13 | ... 14 | } 15 | 16 | export default metrics(config, options)(Application); 17 | ``` 18 | 19 | ES7 decorator 20 | 21 | ```javascript 22 | @metrics(config, options) 23 | class Application extends React.Component { 24 | ... 25 | } 26 | ``` 27 | 28 | #### `config` 29 | 30 | A configuration object to create metrics instance. 31 | 32 | Example: 33 | 34 | ```javascript 35 | { 36 | enabled: true, 37 | vendors: [ 38 | { 39 | name: "Google Analytics", 40 | api: new GoogleAnalytics({ 41 | trackingId: "UA-********-*" 42 | }) 43 | }, 44 | { 45 | name: "Adobe Tag Manager" 46 | api: new AdobeTagManager({ 47 | seedFile: "****" 48 | }) 49 | } 50 | ], 51 | pageViewEvent: "pageLoad", 52 | pageDefaults: () => { 53 | return { 54 | siteName: "My Web Site", 55 | ... 56 | }; 57 | }, 58 | customParams: { 59 | ... 60 | }, 61 | debug: true 62 | } 63 | 64 | ``` 65 | 66 | - `vendors`(required) - An array of tracking api configuration object for each [vendor](/docs/Guides.md#vendor). 67 | - `enabled`(optional) - A flag to enable or disable metrics functionality. Default value is `true`. 68 | - `pageViewEvent`(optional) - A default page view event name. You can optionally override this value by sending other event name from page view call. Default value is `pageLoad`. 69 | - `pageDefaults`(optional) - A function to return common page view tracking metrics that's sent for all page view call. This will receive [`routeState`](/docs/Guides.md#routeState) argument where you can use to send route specific information. Default value is a function which returns an empty object. 70 | - `customParams`(optional) - An optional object which gets merged into `pageDefaults` if specified. 71 | - `requestTimeout`(optional) - An optional time out value for the tracking request if specified. Default value is `15000` ms. 72 | - `cancelOnNext`(optional) - An optional flag to indicate whether the pending request should be canceled when the route changes if specified. Default value is `true`. 73 | - `getRouteState`(optional) - A function which returns the new [`routeState`](/docs/Guides.md#routeState) upon route change, returns `null` otherwise. This takes old and new props as arguments. Pass your own function to override [default logic](/src/react/getRouteState.js). 74 | - `findRouteComponent`(optional) - A function which returns the route handler component. Pass your own function to override [default logic](/src/react/findRouteComponent.js). 75 | 76 | #### `options` 77 | 78 | Example: 79 | 80 | ``` 81 | { 82 | autoTrackPageView: false, 83 | useTrackBinding: true, 84 | attributePrefix: "custom", 85 | suppressTrackBindingWarning: true 86 | } 87 | ``` 88 | 89 | - `autoTrackPageView`(optional) - A flag to indicate whether a page view is triggered automatically by a route change detection or not. Default value is `true`. 90 | - `useTrackBinding`(optional) - A flag to indicate whether metrics should use track binding. Default value is `true`. 91 | - `attributePrefix`(optional) - An element attribute prefix to use for tracking bining. Default value is `data-metrics`. 92 | - `suppressTrackBindingWarning`(optional) - A flag to indicate whether the warning which is presented when both the default track binding and [`MetricsElement`](#MetricsElement) are used should be suppressed or not. Default value is `false`. 93 | 94 | ### [`exposeMetrics`](#exposeMetrics) 95 | 96 | Wraps your component and returns wrapped component which is aware of `willTrackPageView` static method in your component. `willTrackPageView` gets called when react-metrics [detects your component as route handler component](/docs/Guides.md#routeHandlerDetection). 97 | `willTrackPageView` will receive [`routeState`](/docs/Guides.md#routeState). 98 | 99 | #### Usage 100 | 101 | ES6 102 | 103 | ```javascript 104 | class MyComponent extends React.Component { 105 | ... 106 | } 107 | 108 | MyComponent.willTrackPageView = (routeState) => { 109 | return myTrackingData; 110 | } 111 | 112 | export default exposeMetrics(MyComponent); 113 | ``` 114 | 115 | ES7 decorator 116 | 117 | ```javascript 118 | @exposeMetrics 119 | class MyComponent extends React.Component { 120 | static willTrackPageView(routeState) { 121 | return myTrackingData; 122 | } 123 | } 124 | ``` 125 | 126 | ### [`PropTypes`](#PropTypes) 127 | 128 | | type | description | 129 | | ------------- | ----------- | 130 | | metrics | A type which exposes all API methods| 131 | | location | A type which contains [route information](/docs/Guides.md#location) | 132 | 133 | 134 | ### [`createMetrics(config)`](#createMetrics) 135 | 136 | Low level factory API which creates [metrics instance](/docs/api/Core.md#metrics-api-references). This can be used for non-React project or for decoupling metrics from React component by using it from Flux store or Redux middleware. 137 | 138 | Example: 139 | 140 | ```javascript 141 | // creating middleware for Redux 142 | 143 | import {createMetrics} from "react-metrics"; 144 | 145 | const metrics = createMetrics(config); 146 | 147 | export default function metricsMiddleware() { 148 | return next => action => { 149 | const returnValue = next(action); 150 | switch (action.type) { 151 | case ActionTypes.ROUTE_CHANGE: 152 | const {location} = action; 153 | const paths = location.pathname.substr(1).split("/"); 154 | const routeState = location; 155 | metrics.setRouteState(routeState); 156 | metrics.api.pageView({ 157 | category: !paths[0] ? "landing" : paths[0] 158 | }); 159 | } 160 | return returnValue; 161 | }; 162 | } 163 | ``` 164 | 165 | ### [`MetricsElement`](#MetricsElement) 166 | 167 | `MetricsElement` is a react component which detects click event on tracking elements within its children including itself and sends tracking data. 168 | 169 | To minimize the child element traversing, it is recommended that you add `MetricsElement` as the closest common parent against the children you are tracking. 170 | 171 | Also, when you use `MetricsElement` in your application, you should stick with `MetricsElement` for all the declarative tracking and turn off the default track binding by passing `useTrackBinding=false` to the [`metrics`](#metrics) options to avoid double tracking accidentally. 172 | 173 | #### Props 174 | 175 | - `element`(optional) - Either a string to indicate a html element, a component class or a component instance to render. 176 | - any arbitrary tracking attributes. 177 | 178 | #### Usage 179 | 180 | Sends tracking data defined as its own props. You can pass the `element` prop with html tag string, component class or component instance. 181 | If a component instance is passed as `element` props, it will be cloned with new props merged into the original props of the component instance. 182 | 183 | Example: 184 | 185 | ```javascript 186 | import React from "react"; 187 | import {MetricsElement} from "react-metrics"; 188 | 189 | const MyComponent = () => ( 190 |
    191 | 192 | 193 | 194 |
    195 | ); 196 | ``` 197 | 198 | If `element` is not set, it renders its children only. 199 | 200 | Example: 201 | 202 | ```javascript 203 | import React from "react"; 204 | import {MetricsElement} from "react-metrics"; 205 | 206 | const MyComponent = () => ( 207 |
    208 | 209 | 210 | 211 | 212 | 213 |
    214 | ); 215 | ``` 216 | 217 | Sends tracking data defined as child component's props. 218 | 219 | Example: 220 | 221 | ```javascript 222 | import React from "react"; 223 | import {MetricsElement} from "react-metrics"; 224 | 225 | const MyComponent = (props) => { 226 | const listItem = props.items.map(item => ( 227 |
  • 233 | {item.title} 234 |
  • 235 | )); 236 | return ( 237 |
    238 | 239 | {listItem} 240 | 241 |
    242 | ); 243 | }; 244 | ``` 245 | 246 | Metrics data defined in `MetricsElement` and its children will get merged. 247 | 248 | Example: 249 | 250 | ```javascript 251 | 255 |
    259 | 260 | 261 | 262 |
    263 | 264 |
    268 |
      269 |
    • Item 1
    • 270 |
    • Item 2
    • 271 |
    • Item 3
    • 272 |
    273 |
    274 |
    275 | ``` 276 | 277 | Clicking `Image 1` will send the following data to `react-metrics`: 278 | 279 | ``` 280 | { 281 | eventName: "imageClick", 282 | params: { 283 | page: "page A", 284 | section: "section 1", 285 | value: "Image 1" 286 | } 287 | } 288 | ``` 289 | 290 | Clicking `Item 1` in the list will send the following data to `react-metrics`: 291 | 292 | ``` 293 | { 294 | eventName: "listItemClick", 295 | params: { 296 | page: "page A", 297 | section: "section 2", 298 | value: "list item 1" 299 | } 300 | } 301 | ``` 302 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # React Metrics 4 | 5 | [![npm Version](https://img.shields.io/npm/v/react-metrics.svg?style=flat-square)](https://www.npmjs.org/package/react-metrics) 6 | [![Build Status](https://img.shields.io/travis/nfl/react-metrics/master.svg?style=flat-square)](https://travis-ci.org/nfl/react-metrics) 7 | [![Dependency Status](https://img.shields.io/david/nfl/react-metrics.svg?style=flat-square)](https://david-dm.org/nfl/react-metrics) 8 | [![codecov.io](https://img.shields.io/codecov/c/github/nfl/react-metrics/master.svg?style=flat-square)](https://codecov.io/github/nfl/react-metrics?branch=master) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests) 10 | 11 | An analytics module for [React](https://github.com/facebook/react). 12 | 13 | ## Requirements 14 | 15 | * React 0.14+ 16 | 17 | ## Browser Requirements 18 | 19 | * IE10+ 20 | 21 | ## Features 22 | 23 | * Unobtrusive feature injection through a root application component. 24 | * Supports page view tracking. 25 | * Supports both imperative and declarative custom link tracking. 26 | * Provides a custom event tracking API. 27 | * Supports multiple simultaneous analytics vendor tracking. 28 | 29 | ## Installation 30 | 31 | ``` 32 | $ npm install --save react-metrics 33 | ``` 34 | 35 | React Metrics depends on [Promise](https://promisesaplus.com/) to be available in browser. If your application support the browser which doesn't support Promise, please include the polyfill. 36 | 37 | ## Getting Started 38 | 39 | ### 1. Configure Metrics 40 | 41 | Refer to the [docs](/docs/) for more information on [configuration](/docs/api/ReactMetrics.md#config). 42 | 43 | ```javascript 44 | // Application.js 45 | import {metrics} from "react-metrics"; 46 | 47 | const config = { 48 | vendors: [{ 49 | name: "Google Analytics", 50 | api: new GoogleAnalytics({ 51 | trackingId: "UA-********-*" 52 | }) 53 | }], 54 | pageViewEvent: "pageLoad", 55 | pageDefaults: () => { 56 | return { 57 | siteName: "My Web Site", 58 | ... 59 | }; 60 | } 61 | } 62 | ``` 63 | 64 | ### 2. Wrap Application Level Component 65 | 66 | Compose your top level application component with `metrics` in order to provide `metrics` to all components and automatically enable page view tracking. 67 | 68 | ```javascript 69 | // Application.js 70 | class Application extends React.Component { 71 | render() { 72 | return ( 73 | {this.props.children} 74 | ); 75 | } 76 | } 77 | export default metrics(config)(Application); 78 | ``` 79 | 80 | Alternatively, if your development environment supports ES7, use the [@decorator](http://babeljs.io/docs/plugins/syntax-decorators/) syntax instead: 81 | 82 | ```javascript 83 | // Application.js 84 | @metrics(config) 85 | class Application extends React.Component { 86 | render() { 87 | return ( 88 | {this.props.children} 89 | ); 90 | } 91 | } 92 | ``` 93 | 94 | Your application will now automatically trigger page view tracking. 95 | 96 | ### 3. Add Custom Link Tracking 97 | 98 | a. Use `data-` attributes to enable custom link tracking on your DOM elements. 99 | 100 | ```javascript 101 | // PaginationComponent.js 102 | class PaginationComponent extends React.Component { 103 | render() { 104 | const {commentId, totalPage, currentPage} = this.props; 105 | return ( 106 | 127 | ); 128 | } 129 | } 130 | ``` 131 | 132 | b. Use [`MetricsElement`](/docs/api/ReactMetrics.md#MetricsElement) for custom link tracking on a nested DOM element. 133 | 134 | Please see [`MetricsElement`](/docs/api/ReactMetrics.md#MetricsElement) for more use cases. 135 | 136 | ```javascript 137 | import {MetricsElement} from "react-metrics"; 138 | // PaginationComponent.js 139 | class PaginationComponent extends React.Component { 140 | render() { 141 | const {commentId, totalPage, currentPage} = this.props; 142 | return ( 143 |
      144 |
    • 0 ? "active" : ""}> 145 | 151 | Back 152 | 153 |
    • 154 |
    • ...
    • 155 |
    • 156 | 162 | Next 163 | 164 |
    • 165 |
    166 | ); 167 | } 168 | } 169 | ``` 170 | 171 | ### 4. Analytics Vendor Implementations 172 | 173 | `react-metrics` does not automatically supply any vendor analytics. You need to integrate with an analytics vendor to actually track something for reporting. 174 | Refer to [Vendor Examples](/examples/vendors) for Omniture, Google Analytics and other implementations. 175 | 176 | Also check out the awesome [segmentio library](https://github.com/segmentio/analytics.js) which provides a lot of third party analytics vendors. 177 | 178 | ## Advanced Usage 179 | 180 | ### Override Default Page View Tracking 181 | 182 | Use the `@exposeMetrics` decorator and `willTrackPageView()` methods on a route-handling component to override the default page view tracking behavior and `pageDefaults` data. 183 | 184 | 1. Example: disable automatic page view tracking and trigger page view tracking manually. 185 | 186 | ```javascript 187 | // PageComponent.js 188 | // Must be a "route handling" component: 189 | 190 | import {exposeMetrics, PropTypes} from "react-metrics"; 191 | 192 | @exposeMetrics 193 | class PageComponent extends React.Component { 194 | static contextTypes = { 195 | metrics: PropTypes.metrics 196 | } 197 | static willTrackPageView() { 198 | // first, suppress the automatic call. 199 | return false; 200 | } 201 | componentDidMount() { 202 | const {value1, value2} = this.props; 203 | this.context.metrics.pageView({value1, value2}); 204 | } 205 | render () { 206 | ... 207 | } 208 | } 209 | ``` 210 | 211 | 2. Example: add custom data to automatic page view tracking. 212 | 213 | ```javascript 214 | // PageComponent.js "route-handler 215 | // A route handling component: 216 | 217 | import {exposeMetrics} from "react-metrics"; 218 | 219 | @exposeMetrics 220 | class PageComponent extends React.Component { 221 | static willTrackPageView(routeState) { 222 | // return a promise that resolves to custom data. 223 | return yourPromise.then(data => { 224 | // data gets merged with `pageDefaults` object 225 | return data; 226 | }); 227 | } 228 | render () { 229 | ... 230 | } 231 | } 232 | ``` 233 | 234 | ### Imperative Custom Event Tracking 235 | 236 | Use `this.context.metrics.track()` to trigger custom event tracking as an alternative to [declarative custom link tracking](/docs/GettingStarted.md#declarative-vs-imperative-tracking). 237 | Define `metrics` as a `contextType` in your component and trigger custom track events using `metrics.track()`. 238 | 239 | ```javascript 240 | import {PropTypes} from "react-metrics"; 241 | 242 | class YourComponent extends React.Component { 243 | static contextTypes = { 244 | metrics: PropTypes.metrics 245 | } 246 | 247 | onSomethingUpdated(value) { 248 | this.context.metrics.track("customEventName", {value}); 249 | } 250 | 251 | render() { 252 | ... 253 | } 254 | } 255 | ``` 256 | 257 | ### Metrics API Outside a React Component 258 | 259 | `react-metrics` provides a low level factory API; this is convenient for exposing an instance of the `metrics` API outside of a React component. 260 | Use `createMetrics` to create a `metrics` instance and expose the `metrics.api`. 261 | 262 | ```javascript 263 | // creating middleware for Redux 264 | 265 | import {createMetrics} from "react-metrics"; 266 | 267 | const metrics = createMetrics(config); 268 | 269 | export default function metricsMiddleware() { 270 | return next => action => { 271 | const returnValue = next(action); 272 | switch (action.type) { 273 | case ActionTypes.ROUTE_CHANGE: 274 | const {location} = action; 275 | const paths = location.pathname.substr(1).split("/"); 276 | const routeState = location; 277 | metrics.setRouteState(routeState); 278 | metrics.api.pageView({ 279 | category: !paths[0] ? "landing" : paths[0] 280 | }); 281 | } 282 | return returnValue; 283 | }; 284 | } 285 | ``` 286 | 287 | ## API, Examples, and Documentation 288 | 289 | * [API](/docs/api/) Review the `metrics` API 290 | * [Getting Started](/docs/GettingStarted.md) A more detailed Getting Started Guide 291 | * [Vendor Examples](/examples/vendors) Omniture, Google Analytics, and other analytics vendor examples. 292 | * [Docs](/docs/) Guides, API, and examples. 293 | 294 | 295 | ## To run examples: 296 | 297 | 1. Clone this repo 298 | 2. Run `npm install` 299 | 3. Run `npm run examples` 300 | 4. Point your browser to http://localhost:8080 301 | 302 | ## Contributing to this project 303 | 304 | Please take a moment to review the [guidelines for contributing](CONTRIBUTING.md). 305 | 306 | * [Pull requests](CONTRIBUTING.md#pull-requests) 307 | * [Development Process](CONTRIBUTING.md#development) 308 | 309 | ## License 310 | 311 | MIT 312 | -------------------------------------------------------------------------------- /test/ReactMetrics/metrics.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, max-nested-callbacks, react/prop-types, no-empty, padded-blocks, jsx-a11y/href-no-hash */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import createHistory from "history/lib/createMemoryHistory"; 5 | import {Router, Route} from "react-router"; 6 | import execSteps from "../execSteps"; 7 | import ReactTestUtils from "react-addons-test-utils"; 8 | import metrics from "../../src/react/metrics"; 9 | import createMetrics, {isMetrics, Metrics} from "../../src/core/createMetrics"; 10 | import exposeMetrics, { 11 | clearMountedInstances 12 | } from "../../src/react/exposeMetrics"; 13 | import PropTypes from "../../src/react/PropTypes"; 14 | import metricsConfig from "../metrics.config"; 15 | import metricsMock from "../metricsMock"; 16 | 17 | describe("metrics", () => { 18 | const defaultData = metricsConfig.pageDefaults(); 19 | let pageDefaultsStub; 20 | let node; 21 | 22 | before(() => { 23 | pageDefaultsStub = sinon.stub( 24 | metricsConfig, 25 | "pageDefaults", 26 | routeState => { 27 | return Object.assign({}, defaultData, { 28 | siteSection: routeState.pathname 29 | }); 30 | } 31 | ); 32 | }); 33 | 34 | after(() => { 35 | pageDefaultsStub.restore(); 36 | }); 37 | 38 | beforeEach(() => { 39 | node = document.createElement("div"); 40 | }); 41 | 42 | afterEach(() => { 43 | try { 44 | ReactDOM.unmountComponentAtNode(node); 45 | } catch (err) {} 46 | }); 47 | 48 | it("should create 'metrics' instance", () => { 49 | @metrics(metricsConfig) 50 | class Application extends React.Component { 51 | render() { 52 | return
    ; 53 | } 54 | } 55 | 56 | const tree = ReactTestUtils.renderIntoDocument(); 57 | 58 | const app = ReactTestUtils.findRenderedComponentWithType( 59 | tree, 60 | Application 61 | ); 62 | expect(isMetrics(app._getMetrics())).to.be.true; 63 | 64 | tree.componentWillUnmount(); 65 | }); 66 | 67 | it("can inject 'metrics' instance", () => { 68 | const metricsInstance = createMetrics(metricsConfig); 69 | 70 | @metrics(metricsInstance) 71 | class Application extends React.Component { 72 | render() { 73 | return
    ; 74 | } 75 | } 76 | 77 | const tree = ReactTestUtils.renderIntoDocument(); 78 | 79 | const app = ReactTestUtils.findRenderedComponentWithType( 80 | tree, 81 | Application 82 | ); 83 | expect(app._getMetrics()).to.eql(metricsInstance); 84 | 85 | tree.componentWillUnmount(); 86 | }); 87 | 88 | it("should make 'metrics' context available", () => { 89 | const metricsContext = {}; 90 | 91 | class Page extends React.Component { 92 | // context unit test fails w/o this, why?? 93 | static contextTypes = { 94 | metrics: PropTypes.metrics 95 | }; 96 | render() { 97 | return

    Page

    ; 98 | } 99 | } 100 | class Page2 extends React.Component { 101 | render() { 102 | return

    Page2

    ; 103 | } 104 | } 105 | const TestPage = exposeMetrics(Page2); 106 | 107 | @metrics(metricsConfig) 108 | class Application extends React.Component { 109 | render() { 110 | return
    ; 111 | } 112 | } 113 | 114 | const stub = sinon.stub(Application.prototype, "_getMetrics", () => { 115 | return { 116 | ...metricsMock, 117 | api: metricsContext 118 | }; 119 | }); 120 | 121 | const tree = ReactTestUtils.renderIntoDocument(); 122 | 123 | const page = ReactTestUtils.findRenderedComponentWithType(tree, Page); 124 | expect(page.context.metrics).to.eql(metricsContext); 125 | 126 | const pageWithMetrics = ReactTestUtils.findRenderedComponentWithType( 127 | tree, 128 | TestPage 129 | ); 130 | expect(pageWithMetrics.context.metrics).to.eql(metricsContext); 131 | 132 | stub.restore(); 133 | tree.componentWillUnmount(); 134 | clearMountedInstances(); 135 | }); 136 | 137 | it("should not auto track page view when 'autoTrackPageView' is set to false.", done => { 138 | @metrics(metricsConfig, {autoTrackPageView: false}) 139 | class Application extends React.Component { 140 | static displayName = "Application"; 141 | 142 | render() { 143 | return
    {this.props.children}
    ; 144 | } 145 | } 146 | 147 | const pageView = sinon.stub(metricsMock.api, "pageView"); 148 | const stub = sinon.stub(Application.prototype, "_getMetrics", () => { 149 | return metricsMock; 150 | }); 151 | 152 | const steps = [ 153 | function() { 154 | expect(pageView.calledOnce).to.be.false; 155 | this.history.pushState(null, "/page"); 156 | }, 157 | function() { 158 | expect(pageView.calledOnce).to.be.true; 159 | stub.restore(); 160 | pageView.restore(); 161 | done(); 162 | } 163 | ]; 164 | 165 | class Page extends React.Component { 166 | static displayName = "Page"; 167 | 168 | static contextTypes = { 169 | metrics: PropTypes.metrics.isRequired 170 | }; 171 | 172 | static willTrackPageView() { 173 | return false; 174 | } 175 | componentDidMount() { 176 | this.context.metrics.pageView(); 177 | } 178 | 179 | render() { 180 | return

    Page

    ; 181 | } 182 | } 183 | 184 | const execNextStep = execSteps(steps, done); 185 | 186 | ReactDOM.render( 187 | 188 | 189 | 190 | 191 | , 192 | node, 193 | execNextStep 194 | ); 195 | }); 196 | 197 | it("should not throw invariant error when `enabled` is set to false in metrics config and pageView is not defined.", done => { 198 | @metrics(metricsConfig) 199 | class Application extends React.Component { 200 | static displayName = "Application"; 201 | 202 | render() { 203 | return

    Appication

    ; 204 | } 205 | } 206 | 207 | sinon.stub(Application.prototype, "_getMetrics", () => { 208 | return { 209 | ...metricsMock, 210 | enabled: false, 211 | api: {} 212 | }; 213 | }); 214 | 215 | expect(() => { 216 | ReactDOM.render( 217 | 218 | 219 | , 220 | node, 221 | done 222 | ); 223 | }).to.not.throw(); 224 | }); 225 | 226 | it("should not use track binding when 'useTrackBinding' is set to false.", done => { 227 | const stub = sinon.stub(Metrics.prototype, "_handleClick"); 228 | const metricsInstance = new Metrics(metricsConfig); 229 | @metrics(metricsInstance, {useTrackBinding: false}) 230 | class Application extends React.Component { 231 | static displayName = "Application"; 232 | componentDidMount() { 233 | // make sure click happens after binding is done. 234 | setTimeout(() => { 235 | this.refs.link.click(); 236 | stub.should.have.callCount(0); 237 | stub.reset(); 238 | done(); 239 | }, 0); 240 | } 241 | render() { 242 | return ( 243 |
    244 | 249 |
    250 | ); 251 | } 252 | } 253 | 254 | ReactDOM.render(, node); 255 | }); 256 | 257 | it("throws when 'pageView' api is not defined in the config when auto page view tracking is triggered.", () => { 258 | @metrics({ 259 | vendors: [ 260 | { 261 | name: "Test", 262 | api: { 263 | track() { 264 | return {}; 265 | } 266 | } 267 | } 268 | ] 269 | }) 270 | class Application extends React.Component { 271 | static displayName = "Application"; 272 | 273 | render() { 274 | return

    Appication

    ; 275 | } 276 | } 277 | 278 | expect(() => { 279 | ReactDOM.render( 280 | 281 | 282 | , 283 | node 284 | ); 285 | }).to.throw(/'pageView' api needs to be defined/); 286 | }); 287 | 288 | it("should be able to manually track page view with custom page view event name.", done => { 289 | const customPageViewRule = "customPageLoad"; 290 | const customData = { 291 | pageName: "I", 292 | content: "J" 293 | }; 294 | 295 | @metrics(metricsConfig) 296 | @exposeMetrics 297 | class Application extends React.Component { 298 | static displayName = "Application"; 299 | 300 | static contextTypes = { 301 | metrics: PropTypes.metrics.isRequired 302 | }; 303 | 304 | static willTrackPageView() { 305 | return false; 306 | } 307 | 308 | componentDidMount() { 309 | this.context.metrics.pageView(customPageViewRule, customData); 310 | } 311 | 312 | render() { 313 | return
    ; 314 | } 315 | } 316 | 317 | const stub = sinon.stub(Application.prototype, "_getMetrics", () => { 318 | return { 319 | api: { 320 | pageView(...args) { 321 | expect(args[0]).to.be.equal(customPageViewRule); 322 | expect(args[1]).to.be.equal(customData); 323 | stub.restore(); 324 | done(); 325 | } 326 | } 327 | }; 328 | }); 329 | 330 | ReactDOM.render(, node); 331 | }); 332 | 333 | it("should be able to manually track.", done => { 334 | const trackId = "customTrackId"; 335 | const customData = { 336 | pageName: "I", 337 | content: "J" 338 | }; 339 | 340 | @metrics(metricsConfig) 341 | @exposeMetrics 342 | class Application extends React.Component { 343 | static displayName = "Application"; 344 | 345 | static contextTypes = { 346 | metrics: PropTypes.metrics.isRequired 347 | }; 348 | 349 | static willTrackPageView() { 350 | return false; 351 | } 352 | 353 | componentDidMount() { 354 | this.context.metrics.track(trackId, customData); 355 | } 356 | 357 | render() { 358 | return
    ; 359 | } 360 | } 361 | 362 | const stub = sinon.stub(Application.prototype, "_getMetrics", () => { 363 | return { 364 | api: { 365 | track(...args) { 366 | expect(args[0]).to.be.equal(trackId); 367 | expect(args[1]).to.be.equal(customData); 368 | stub.restore(); 369 | done(); 370 | } 371 | } 372 | }; 373 | }); 374 | 375 | ReactDOM.render(, node); 376 | }); 377 | }); 378 | -------------------------------------------------------------------------------- /test/core/useTrackBindingPlugin.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import useTrackBindingPlugin, { 3 | TrackBindingPlugin 4 | } from "../../src/core/useTrackBindingPlugin"; 5 | import {addChildToNode, removeChildFromNode} from "../nodeUtils"; 6 | 7 | describe("useTrackBindingPlugin", () => { 8 | let node; 9 | let listener; 10 | 11 | before(() => { 12 | node = document.createElement("div"); 13 | document.body.appendChild(node); 14 | }); 15 | 16 | after(() => { 17 | document.body.removeChild(node); 18 | }); 19 | 20 | afterEach(() => { 21 | removeChildFromNode(node); 22 | if (listener) { 23 | listener.remove(); 24 | listener = null; 25 | } 26 | }); 27 | 28 | it("throws when callback is not a function", () => { 29 | expect(() => { 30 | listener = useTrackBindingPlugin({}); 31 | }).to.throw("callback needs to be a function."); 32 | }); 33 | 34 | it("throws when invalid element is passed", () => { 35 | expect(() => { 36 | listener = useTrackBindingPlugin({ 37 | callback: () => {}, 38 | rootElement: document.createDocumentFragment() 39 | }); 40 | }).to.throw("rootElement needs to be a valid node element."); 41 | }); 42 | 43 | it("does not listen twice", () => { 44 | addChildToNode(node, { 45 | tagName: "a", 46 | attrs: { 47 | href: "#", 48 | "data-metrics-event-name": "myEvent", 49 | "data-metrics-prop": "value" 50 | }, 51 | content: "Link to Track" 52 | }); 53 | 54 | const plugin = new TrackBindingPlugin(); 55 | const spy = sinon.spy(); 56 | plugin.listen(spy, node); 57 | listener = plugin.listen(spy, node); 58 | 59 | const linkNode = node.firstChild; 60 | linkNode.click(); 61 | 62 | spy.should.have.callCount(1); 63 | }); 64 | 65 | it("calls callback function with expected arguments when an element is clicked", done => { 66 | addChildToNode(node, { 67 | tagName: "a", 68 | attrs: { 69 | href: "#", 70 | "data-metrics-event-name": "myEvent", 71 | "data-metrics-prop": "value" 72 | }, 73 | content: "Link to Track" 74 | }); 75 | 76 | function callback(eventName, params) { 77 | expect(eventName).to.equal("myEvent"); 78 | expect(params).to.eql({prop: "value"}); 79 | done(); 80 | } 81 | 82 | listener = useTrackBindingPlugin({ 83 | callback, 84 | rootElement: node 85 | }); 86 | 87 | const linkNode = node.firstChild; 88 | linkNode.click(); 89 | }); 90 | 91 | it("only tracks click under specified element tree", () => { 92 | addChildToNode(document.body, { 93 | tagName: "a", 94 | attrs: { 95 | id: "linkInDoc", 96 | href: "#", 97 | "data-metrics-event-name": "myEvent", 98 | "data-metrics-prop": "value" 99 | }, 100 | content: "Link to Track" 101 | }); 102 | 103 | const spy = sinon.spy(); 104 | listener = useTrackBindingPlugin({ 105 | callback: spy, 106 | rootElement: node 107 | }); 108 | 109 | const linkNode = document.getElementById("linkInDoc"); 110 | linkNode.click(); 111 | 112 | expect(spy.calledOnce).to.be.false; 113 | 114 | document.body.removeChild(linkNode); 115 | }); 116 | 117 | it("does not require 'rootElement'", done => { 118 | addChildToNode(node, { 119 | tagName: "a", 120 | attrs: { 121 | href: "#", 122 | "data-metrics-event-name": "myEvent", 123 | "data-metrics-prop": "value" 124 | }, 125 | content: "Link to Track" 126 | }); 127 | 128 | function callback(eventName, params) { 129 | expect(eventName).to.equal("myEvent"); 130 | expect(params).to.eql({prop: "value"}); 131 | done(); 132 | } 133 | listener = useTrackBindingPlugin({ 134 | callback 135 | }); 136 | 137 | const linkNode = node.firstChild; 138 | linkNode.click(); 139 | }); 140 | 141 | it("allows custom tracking attribute prefix", done => { 142 | const attributePrefix = "metrics"; 143 | 144 | addChildToNode(node, { 145 | tagName: "a", 146 | attrs: { 147 | href: "#", 148 | "metrics-event-name": "myEvent", 149 | "metrics-prop": "value" 150 | }, 151 | content: "Link to Track" 152 | }); 153 | 154 | function callback(eventName, params) { 155 | expect(eventName).to.equal("myEvent"); 156 | expect(params).to.eql({prop: "value"}); 157 | done(); 158 | } 159 | 160 | listener = useTrackBindingPlugin({ 161 | callback, 162 | rootElement: node, 163 | attributePrefix 164 | }); 165 | 166 | const linkNode = node.firstChild; 167 | linkNode.click(); 168 | }); 169 | 170 | it("properly unlisten", () => { 171 | addChildToNode(node, { 172 | tagName: "a", 173 | attrs: { 174 | href: "#", 175 | "data-metrics-event-name": "myEvent", 176 | "data-metrics-prop": "value" 177 | }, 178 | content: "Link to Track" 179 | }); 180 | 181 | const spy = sinon.spy(); 182 | listener = useTrackBindingPlugin({ 183 | callback: spy, 184 | rootElement: node 185 | }); 186 | 187 | listener.remove(); 188 | listener = null; 189 | 190 | const linkNode = node.firstChild; 191 | linkNode.click(); 192 | 193 | expect(spy.calledOnce).to.be.false; 194 | }); 195 | 196 | it("does not call 'callback' when tracking attributes are not found", () => { 197 | addChildToNode(node, { 198 | tagName: "a", 199 | attrs: { 200 | href: "#" 201 | }, 202 | content: "Link to Track" 203 | }); 204 | 205 | const spy = sinon.spy(); 206 | listener = useTrackBindingPlugin({ 207 | callback: spy, 208 | rootElement: node 209 | }); 210 | 211 | const linkNode = node.firstChild; 212 | linkNode.click(); 213 | 214 | expect(spy.calledOnce).to.be.false; 215 | }); 216 | 217 | it("does not call 'callback' without 'eventName'", () => { 218 | addChildToNode(node, { 219 | tagName: "a", 220 | attrs: { 221 | href: "#", 222 | "data-metrics-prop": "value" 223 | }, 224 | content: "Link to Track" 225 | }); 226 | 227 | const spy = sinon.spy(); 228 | listener = useTrackBindingPlugin({ 229 | callback: spy, 230 | rootElement: node 231 | }); 232 | 233 | const linkNode = node.firstChild; 234 | linkNode.click(); 235 | 236 | expect(spy.calledOnce).to.be.false; 237 | }); 238 | 239 | it("calls 'callback' with empty 'params'", done => { 240 | addChildToNode(node, { 241 | tagName: "a", 242 | attrs: { 243 | href: "#", 244 | "data-metrics-event-name": "myEvent" 245 | }, 246 | content: "Link to Track" 247 | }); 248 | 249 | function callback(eventName, params) { 250 | expect(eventName).to.equal("myEvent"); 251 | expect(params).to.eql({}); 252 | done(); 253 | } 254 | 255 | listener = useTrackBindingPlugin({ 256 | callback, 257 | rootElement: node 258 | }); 259 | 260 | const linkNode = node.firstChild; 261 | linkNode.click(); 262 | }); 263 | 264 | it("does not call 'callback' when event is not single left click", () => { 265 | addChildToNode(node, { 266 | tagName: "a", 267 | attrs: { 268 | href: "#", 269 | "data-metrics-event-name": "myEvent" 270 | }, 271 | content: "Link to Track" 272 | }); 273 | 274 | const spy = sinon.spy(); 275 | 276 | listener = useTrackBindingPlugin({ 277 | callback: spy, 278 | rootElement: node 279 | }); 280 | 281 | const linkNode = node.firstChild; 282 | listener.target._handleClick(spy, { 283 | target: linkNode, 284 | button: 1 285 | }); 286 | 287 | expect(spy.calledOnce).to.be.false; 288 | 289 | listener.target._handleClick(spy, { 290 | target: linkNode, 291 | button: 0, 292 | ctrlKey: true 293 | }); 294 | 295 | expect(spy.calledOnce).to.be.false; 296 | }); 297 | 298 | it("does not call 'callback' when no tracking data is found", () => { 299 | addChildToNode(node, { 300 | tagName: "a", 301 | attrs: { 302 | href: "#" 303 | }, 304 | content: "Link to Track" 305 | }); 306 | 307 | const spy = sinon.spy(); 308 | 309 | listener = useTrackBindingPlugin({ 310 | callback: spy, 311 | rootElement: node, 312 | traverseParent: true 313 | }); 314 | 315 | const linkNode = node.firstChild; 316 | listener.target._handleClick(spy, { 317 | target: linkNode, 318 | button: 0 319 | }); 320 | 321 | expect(spy.calledOnce).to.be.false; 322 | }); 323 | 324 | it("merges pageDefaults data when '{prefix}-merge-pagedefaults' is set to 'true'", done => { 325 | addChildToNode(node, { 326 | tagName: "a", 327 | attrs: { 328 | href: "#", 329 | "data-metrics-event-name": "myEvent", 330 | "data-metrics-prop": "value", 331 | "data-metrics-merge-pagedefaults": "true" 332 | }, 333 | content: "Link to Track" 334 | }); 335 | 336 | function callback(eventName, params, merge) { 337 | expect(eventName).to.equal("myEvent"); 338 | expect(params).to.eql({prop: "value"}); 339 | expect(merge).to.be.true; 340 | done(); 341 | } 342 | 343 | listener = useTrackBindingPlugin({ 344 | callback, 345 | rootElement: node 346 | }); 347 | 348 | const linkNode = node.firstChild; 349 | linkNode.click(); 350 | }); 351 | 352 | it("aggregates metrics data up to the root element", done => { 353 | const childNode = addChildToNode(node, { 354 | tagName: "div", 355 | attrs: { 356 | "data-metrics-prop": "value" 357 | } 358 | }); 359 | 360 | addChildToNode(childNode, { 361 | tagName: "a", 362 | attrs: { 363 | href: "#", 364 | "data-metrics-event-name": "myEvent", 365 | "data-metrics-prop1": "value1" 366 | }, 367 | content: "Link to Track" 368 | }); 369 | 370 | function callback(eventName, params) { 371 | expect(eventName).to.equal("myEvent"); 372 | expect(params).to.eql({ 373 | prop: "value", 374 | prop1: "value1" 375 | }); 376 | done(); 377 | } 378 | 379 | listener = useTrackBindingPlugin({ 380 | callback, 381 | rootElement: node, 382 | traverseParent: true 383 | }); 384 | 385 | const linkNode = childNode.firstChild; 386 | linkNode.click(); 387 | }); 388 | 389 | it("overrides metrics data from parent to child", done => { 390 | const childNode = addChildToNode(node, { 391 | tagName: "div", 392 | attrs: { 393 | "data-metrics-event-name": "parentEvent", 394 | "data-metrics-prop": "value" 395 | } 396 | }); 397 | 398 | addChildToNode(childNode, { 399 | tagName: "a", 400 | attrs: { 401 | href: "#", 402 | "data-metrics-event-name": "myEvent", 403 | "data-metrics-prop": "value-overriden", 404 | "data-metrics-prop1": "value1" 405 | }, 406 | content: "Link to Track" 407 | }); 408 | 409 | function callback(eventName, params) { 410 | expect(eventName).to.equal("myEvent"); 411 | expect(params).to.eql({ 412 | prop: "value-overriden", 413 | prop1: "value1" 414 | }); 415 | done(); 416 | } 417 | 418 | listener = useTrackBindingPlugin({ 419 | callback, 420 | rootElement: node, 421 | traverseParent: true 422 | }); 423 | 424 | const linkNode = childNode.firstChild; 425 | linkNode.click(); 426 | }); 427 | 428 | it("aggregates metrics data only when 'traverseParent' is set to true", done => { 429 | const childNode = addChildToNode(node, { 430 | tagName: "div", 431 | attrs: { 432 | "data-metrics-prop": "value" 433 | } 434 | }); 435 | 436 | addChildToNode(childNode, { 437 | tagName: "a", 438 | attrs: { 439 | href: "#", 440 | "data-metrics-event-name": "myEvent", 441 | "data-metrics-prop1": "value1" 442 | }, 443 | content: "Link to Track" 444 | }); 445 | 446 | function callback(eventName, params) { 447 | expect(eventName).to.equal("myEvent"); 448 | expect(params).to.eql({ 449 | prop1: "value1" 450 | }); 451 | done(); 452 | } 453 | 454 | listener = useTrackBindingPlugin({ 455 | callback, 456 | rootElement: node, 457 | traverseParent: false 458 | }); 459 | 460 | const linkNode = childNode.firstChild; 461 | linkNode.click(); 462 | }); 463 | }); 464 | -------------------------------------------------------------------------------- /docs/GettingStarted.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | `react-metrics` expects [`location`](/docs/Guides.md#location) to be passed as prop. If you are using [React Router](https://github.com/rackt/react-router), you can skip to [Step 2](#step2). 4 | 5 | ### [Step 1](#step1) 6 | 7 | Create a container component. 8 | 9 | ```javascript 10 | class ApplicationContainer extends React.Component { 11 | // any logic to `setState` with `location` here. 12 | render() { 13 | return ( 14 | 15 | ); 16 | } 17 | } 18 | ``` 19 | 20 | You can check out the example implementation [here](/examples/no-router-lib). 21 | 22 | ### [Step 2](#step2) 23 | 24 | Wrap your Application component with [`metrics`](/docs/api/ReactMetrics.md#metrics) and pass [`config`](/docs/api/ReactMetrics.md#config). 25 | 26 | ```javascript 27 | import {metrics} from "react-metrics"; 28 | 29 | class Application extends React.Component { 30 | render() { 31 | return ( 32 | {this.props.children} 33 | ); 34 | } 35 | } 36 | 37 | const WrappedApplication = metrics({ 38 | vendors: [{ 39 | name: "Google Analytics", 40 | api: new GoogleAnalytics({ 41 | trackingId: ... 42 | }) 43 | }], 44 | pageDefaults: ... 45 | })(Application); 46 | ``` 47 | 48 | With ES7 decorator syntax, 49 | 50 | ```javascript 51 | import {metrics} from "react-metrics"; 52 | 53 | @metrics({ 54 | vendors: [{ 55 | name: "Google Analytics", 56 | api: new GoogleAnalytics({ 57 | trackingId: ... 58 | }) 59 | }], 60 | pageDefaults: ... 61 | }) 62 | class Application extends React.Component { 63 | render() { 64 | return ( 65 | {this.props.children} 66 | ); 67 | } 68 | } 69 | ``` 70 | 71 | You can make configuration a shared module which you can import into all your various applications. 72 | 73 | ```javascript 74 | import metricsConfig from "shared-metrics-config"; 75 | 76 | @metrics(metricsConfig) 77 | class Application extends React.Component { 78 | render() { 79 | return ( 80 | {this.props.children} 81 | ); 82 | } 83 | } 84 | ``` 85 | 86 | Additionally you can pass `customParams` that applies to the specific application which gets merged into the metrics returned from `pageDefaults` option. 87 | 88 | ```javascript 89 | import metricsConfig from "shared-metrics-config"; 90 | 91 | @metrics(Object.assign(metricsConfig, { 92 | customParams: { 93 | siteName: "My Site" 94 | } 95 | }) 96 | class Application extends React.Component { 97 | render() { 98 | return ( 99 | {this.props.children} 100 | ); 101 | } 102 | } 103 | ``` 104 | 105 | ### [Step 3](#step3) 106 | 107 | Render React Application. 108 | 109 | ```javascript 110 | ReactDOM.render(( 111 | 112 | ), document.getElementById("root")); 113 | ``` 114 | 115 | If you are using [React Router](https://github.com/rackt/react-router), 116 | 117 | ```javascript 118 | ReactDOM.render(( 119 | 120 | 121 | ... 122 | 123 | 124 | ), document.getElementById("root")); 125 | ``` 126 | 127 | You can check out the example implementation using React Router [here](/examples/react-router). 128 | 129 | ### Page View Tracking 130 | 131 | `react-metrics` will automatically detects route change and fires page view call for you. 132 | 133 | Define the metrics you want to track across the pages in the `pageDefaults` option 134 | 135 | Example: 136 | 137 | ```javascript 138 | export default metrics({ 139 | vendors: [{ 140 | name: ..., 141 | api: { 142 | ... 143 | } 144 | }], 145 | pageViewEvent: "MyPageViewEvent", 146 | pageDefaults: (routeState) => { 147 | const paths = routeState.pathname.substr(1).split("/"); 148 | return { 149 | category: !paths[0] ? "landing" : paths[0] 150 | timestamp: Date.now(), 151 | ... 152 | }; 153 | } 154 | })(Application); 155 | ``` 156 | 157 | `pageDefaults` gets called every time the page view tracking happens. It receives [`routeState`](/docs/Guides.md#routeState) which is convenient to send route specific information. 158 | 159 | If you want to include metrics data which is fetched from remote location upon the route change, you can define static `willTrackPageView` method in your route handler component which becomes available when you wrap your component with `exposeMetrics`. Then `willTrackPageView` gets called when auto page view is about to be fired. 160 | 161 | ```javascript 162 | import {exposeMetrics} from "react-metrics"; 163 | 164 | @exposeMetrics 165 | class PageComponent extends React.Component { 166 | static willTrackPageView(routeState) { 167 | return yourPromise.then(data => { 168 | // this gets merged with the data returned by `pageDefaults` 169 | return data; 170 | }); 171 | } 172 | render () { 173 | ... 174 | } 175 | } 176 | ``` 177 | 178 | You can disable the automatic page view tracking and instead manually track page views by using `this.context.metrics.pageView()` 179 | 180 | ```javascript 181 | import {exposeMetrics, PropTypes} from "react-metrics"; 182 | 183 | @exposeMetrics 184 | class PageComponent extends React.Component { 185 | static contextTypes = { 186 | metrics: PropTypes.metrics 187 | } 188 | static willTrackPageView() { 189 | // first, suppress the automatic call. 190 | return false; 191 | } 192 | componentDidMount() { 193 | const {value1, value2} = this.props; 194 | this.context.metrics.pageView({value1, value2}); 195 | } 196 | render () { 197 | ... 198 | } 199 | } 200 | ``` 201 | 202 | ### Custom Link Tracking 203 | 204 | #### Declarative vs Imperative tracking 205 | 206 | There are 2 ways to call `track` api from your component. 207 | 208 | 1. **Declarative** by adding the attributes `data-metrics-event-name` and `data-metrics-*` to a html element. `data-metrics-event-name` is the event name used for the metrics vendor. 209 | 210 | **Note:** `YourComponent` need to be in the child tree of the wrapped component so that the click event is bubbled up to the wrapped component's root node. 211 | 212 | Example: 213 | 214 | ```javascript 215 | class PaginationComponent extends React.Component { 216 | render() { 217 | const {commentId, totalPage, currentPage} = this.props; 218 | return ( 219 | 240 | ); 241 | } 242 | } 243 | ``` 244 | 245 | In a case where the element you are tracking is not the click target, you can use [`MetricsElement`](/docs/api/ReactMetrics.md#MetricsElement). 246 | If you use [`MetricsElement`](/docs/api/ReactMetrics.md#MetricsElement) for all declarative tracking, we recommend turning off default track-binding by passing `useTrackBinding: false` in the [`metrics`](/docs/api/ReactMetrics.md#metrics) options. 247 | 248 | Example: 249 | 250 | ```javascript 251 | import {MetricsElement} from "react-metrics"; 252 | 253 | class PaginationComponent extends React.Component { 254 | render() { 255 | const {commentId, totalPage, currentPage} = this.props; 256 | return ( 257 | 258 |
  • 0 ? "active" : ""}> 259 | 264 | Back 265 | 266 |
  • 267 |
  • ...
  • 268 |
  • 269 | 274 | Next 275 | 276 |
  • 277 |
    278 | ); 279 | } 280 | } 281 | ``` 282 | 283 | If you set `{prefix}-merge-pagedefaults="true"` to the declarative tracking, the custom link tracking metrics will get merged with `pageDefaults` metrics. 284 | 285 | 2. **Imperative** by calling the API explicitly. To do this, define `metrics` context as one of `contextTypes` in your child component. This allows you to call the `track` API. You can pass either an Object or a Promise as a second argument. It's your responsibility to implement the `track` API in your service, otherwise calling the API simply throws an error. 286 | 287 | Example: 288 | 289 | ```javascript 290 | import {PropTypes} from "react-metrics"; 291 | 292 | class YourComponent extends React.Component { 293 | static contextTypes = { 294 | metrics: PropTypes.metrics 295 | } 296 | 297 | onSomethingUpdated(value) { 298 | this.context.metrics.track("customEventName", {value}); 299 | } 300 | 301 | render() { 302 | ... 303 | } 304 | } 305 | ``` 306 | 307 | ### Using your metrics vendor 308 | 309 | To send tracking to your metrics vendor, you can create your own service class with API methods. 310 | 311 | ```javascript 312 | class YourApiClass { 313 | pageView(eventName, params) { 314 | // your page view tracking logic here. 315 | } 316 | track(eventName, params) { 317 | // your custom link tracking logic here. 318 | } 319 | } 320 | ``` 321 | 322 | or just as a plain object 323 | 324 | ```javascript 325 | const YourApiObject = { 326 | pageView: function (eventName, params) { 327 | // your page view tracking logic here. 328 | }, 329 | track: function (eventName, params) { 330 | // your custom link tracking logic here. 331 | } 332 | } 333 | ``` 334 | 335 | You don't have to return anything in your API, but if you do, it will be included as `response` in the tracking event payload. 336 | `react-metrics` will determine the success of the request in the form of `Promise`, so if you don't return anything, which `react-metrics` receives as `undefined` and convert it to `Promise.resolve(undefined)`, the request will be treated as success. 337 | 338 | If you need any initialization steps before `pageView` or `track` is called, we recommend you use a promise for lazy initialization. 339 | 340 | Example: 341 | 342 | ```javascript 343 | class YourApiClass { 344 | pageView(eventName, params) { 345 | return new Promise((resolve, reject) => { 346 | this.loadScript() 347 | .then(globalVendorObject => { 348 | const result = globalVendorObject.track(eventName, params); 349 | if (result) { 350 | resolve({ 351 | eventName, 352 | params 353 | }); 354 | } else { 355 | reject(new Error("tracking request failed")); 356 | } 357 | }) 358 | .catch(err => { 359 | reject(err); 360 | }); 361 | }); 362 | } 363 | track(eventName, params) { 364 | // your custom link tracking logic here. 365 | } 366 | loadScript() { 367 | return this._promise || (this._promise = new Promise((resolve, reject) => { 368 | if (window.globalVendorObject) { 369 | resolve(window.globalVendorObject); 370 | } else { 371 | const script = document.createElement("script"); 372 | 373 | script.onload = () => { 374 | resolve(window.globalVendorObject); 375 | }; 376 | 377 | script.onerror = error => { 378 | reject(error); 379 | }; 380 | 381 | script.src = "//some.vendor.com/lib.js"; 382 | document.head.appendChild(script); 383 | } 384 | })); 385 | } 386 | } 387 | ``` 388 | 389 | More complete example [here](/examples/vendors/AdobeTagManager.js). 390 | 391 | Then pass the class definition or an object to the `vendor.api`. You can also pass an instance of your service class if you want to pass some constructor arguments. 392 | You can define the name of the service which will be included in the response for each tracking request in `vendor.name`. 393 | 394 | ```javascript 395 | class Application extends React.Component { 396 | render() { 397 | return ( 398 |
    {this.props.children}
    399 | ); 400 | } 401 | } 402 | export default metrics({ 403 | vendors: [{ 404 | name: "Your Metrics Service Name", 405 | api: new YourApiClass(options) 406 | }], 407 | pageDefaults: ... 408 | })(Application); 409 | ``` 410 | 411 | ### Tracking custom metrics 412 | 413 | You can define any API method in your service and you can call it from context object. 414 | Let's say if your service defines `setUser` method which stores user identity, or `videoMileStone` method to track video consumption 415 | 416 | ```javascript 417 | const YourApiObject = { 418 | pageView: function (eventName, params) { 419 | ... 420 | }, 421 | track: function (eventName, params) { 422 | ... 423 | }, 424 | setUser: function (user) { 425 | // sends or store user information 426 | }, 427 | videoMileStone: function(milestone) { 428 | // sends video consumption 429 | } 430 | } 431 | ``` 432 | 433 | Then you can call them from your component. 434 | 435 | ```javascript 436 | import {PropTypes} from "react-metrics"; 437 | 438 | class PageComponent extends React.Component { 439 | static contextTypes = { 440 | metrics: PropTypes.metrics 441 | } 442 | 443 | onSetUser(user) { 444 | this.context.metrics.setUser(user); 445 | } 446 | 447 | onVideoPlaybackTimeChange(time) { 448 | if (...) { 449 | ... 450 | this.context.metrics.videoMileStone({videoId, milestone, time}); 451 | } 452 | } 453 | 454 | render () { 455 | ... 456 | } 457 | } 458 | ``` 459 | 460 | ### How about using metrics outside of React Component? 461 | 462 | Yes, you can send metrics outside of React Component. Please see [here](/docs/api/ReactMetrics.md#createMetrics). 463 | --------------------------------------------------------------------------------