├── .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 |
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 |
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 |
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 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/react-router/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 | React Router Example
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/no-router-lib/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 | React Router Example
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/react-router/metricsElement/index.html:
--------------------------------------------------------------------------------
1 |
2 | React Router Example
3 |
4 |
5 |
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 | Manual Track
40 |
45 | Declarative Track
46 |
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 |
31 | Home
32 | Async Page View Track
33 |
34 |
35 | Async Page View Track with query param
36 |
37 |
38 | Manual Page View Track
39 |
40 | Page View Track with params
41 |
42 |
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 |
90 |
91 |
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 | onInclementClick(params.id)}>
110 | Inclement
111 |
112 | onDeclementClick(params.id)}>
113 | Declement
114 |
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 |
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 |
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 |
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 | [](https://www.npmjs.org/package/react-metrics)
6 | [](https://travis-ci.org/nfl/react-metrics)
7 | [](https://david-dm.org/nfl/react-metrics)
8 | [](https://codecov.io/github/nfl/react-metrics?branch=master)
9 | [](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 |
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 |
--------------------------------------------------------------------------------