├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src └── index.js └── test ├── index.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "optional": [ 3 | "es7.objectRestSpread" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .babelrc -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.1" 4 | after_script: 5 | - npm run coveralls 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://img.shields.io/travis/markdalgleish/redux-analytics/master.svg?style=flat-square)](http://travis-ci.org/markdalgleish/redux-analytics) [![Coverage Status](https://img.shields.io/coveralls/markdalgleish/redux-analytics/master.svg?style=flat-square)](https://coveralls.io/r/markdalgleish/redux-analytics) [![npm](https://img.shields.io/npm/v/redux-analytics.svg?style=flat-square)](https://www.npmjs.com/package/redux-analytics) 2 | 3 | # redux-analytics 4 | 5 | Analytics middleware for [Redux](https://github.com/rackt/redux). 6 | 7 | ```bash 8 | $ npm install --save redux-analytics 9 | ``` 10 | 11 | Want to customise your metadata further? Check out [redux-tap](https://github.com/markdalgleish/redux-tap). 12 | 13 | ## Usage 14 | 15 | First, add some `analytics` metadata to your actions using the [Flux Standard Action](https://github.com/acdlite/flux-standard-action) pattern: 16 | 17 | ```js 18 | const action = { 19 | type: 'MY_ACTION', 20 | meta: { 21 | analytics: { 22 | type: 'my-analytics-event', 23 | payload: { 24 | some: 'data', 25 | more: 'stuff' 26 | } 27 | } 28 | } 29 | }; 30 | ``` 31 | 32 | Note that the `analytics` metadata must also be a [Flux Standard Action](https://github.com/acdlite/flux-standard-action). If this isn't the case, an error will be printed to the console. 33 | 34 | Then, write the middleware to handle the presence of this metadata: 35 | 36 | ```js 37 | import analytics from 'redux-analytics'; 38 | import track from 'my-awesome-analytics-library'; 39 | 40 | const middleware = analytics(({ type, payload }) => track(type, payload)); 41 | ``` 42 | 43 | If you need to expose shared analytics data to multiple events, your entire state tree is provided as the second argument. 44 | 45 | ```js 46 | import analytics from 'redux-analytics'; 47 | import track from 'my-awesome-analytics-library'; 48 | 49 | const middleware = analytics(({ type, payload }, state) => { 50 | track(type, { ...state.analytics, ...payload }); 51 | }); 52 | ``` 53 | 54 | If you'd like to use a different meta property than `analytics`, a custom selector function can be provided as the second argument. 55 | 56 | The selector function is only invoked if the action has a `meta` property, and is provided the entire action as an argument. If the selector returns a falsy value, it will be ignored. 57 | 58 | ```js 59 | // Given the following middleware configuration: 60 | const select = ({ meta }) => meta.foobar; 61 | const middleware = analytics(({ type, payload }) => track(type, payload), select); 62 | 63 | // You can then format a trackable action like this: 64 | const action = { 65 | type: 'MY_ACTION', 66 | meta: { 67 | foobar: { 68 | type: 'my-analytics-event' 69 | } 70 | } 71 | }; 72 | ``` 73 | 74 | ## Thanks 75 | 76 | [@pavelvolek](https://github.com/pavelvolek) and [@arturmuller](https://github.com/arturmuller) for providing the initial inspiration with [redux-keen](https://github.com/pavelvolek/redux-keen). 77 | 78 | ## License 79 | 80 | [MIT License](http://markdalgleish.mit-license.org/) 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-analytics", 3 | "version": "0.3.1", 4 | "description": "Analytics middleware for Redux", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "babel-istanbul cover _mocha && babel-istanbul check-coverage --branches 100", 8 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 9 | "build": "rm -rf lib/ && ./node_modules/.bin/babel -d lib/ src/", 10 | "prepublish": "npm run build" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/markdalgleish/redux-analytics.git" 15 | }, 16 | "author": "Mark Dalgleish", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/markdalgleish/redux-analytics/issues" 20 | }, 21 | "homepage": "https://github.com/markdalgleish/redux-analytics#readme", 22 | "devDependencies": { 23 | "babel": "^5.8.23", 24 | "babel-istanbul": "^0.3.20", 25 | "chai": "^3.3.0", 26 | "coveralls": "^2.11.6", 27 | "mocha": "^2.3.3", 28 | "redux": "^3.0.2", 29 | "sinon": "^1.17.1" 30 | }, 31 | "dependencies": { 32 | "flux-standard-action": "^0.6.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { isFSA } from 'flux-standard-action'; 2 | 3 | export default (track, select = ({ meta }) => meta.analytics) => store => next => action => { 4 | const returnValue = next(action); 5 | 6 | if (!action || !action.meta) { 7 | return returnValue; 8 | } 9 | 10 | const event = select(action); 11 | 12 | if (!event) { 13 | return returnValue; 14 | } 15 | 16 | if (!isFSA(event)) { 17 | const message = "The following event wasn't tracked because it isn't a Flux Standard Action (https://github.com/acdlite/flux-standard-action)"; 18 | console.error(message, event); 19 | return returnValue; 20 | } 21 | 22 | track(event, store.getState()); 23 | 24 | return returnValue; 25 | }; 26 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { spy } from 'sinon'; 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import analytics from '../src'; 5 | 6 | describe('Given: A Store with analytics middleware', () => { 7 | 8 | let store; 9 | let eventCallbackSpy; 10 | 11 | beforeEach(() => { 12 | eventCallbackSpy = spy(); 13 | const createStoreWithMiddleware = applyMiddleware(analytics(eventCallbackSpy))(createStore); 14 | const initialState = { name: 'jane smith', loggedIn: false }; 15 | const reducer = (state = initialState, action) => { 16 | switch (action.type) { 17 | case 'LOGIN': { 18 | return { 19 | ...state, 20 | loggedIn: true 21 | }; 22 | } 23 | 24 | default: { 25 | return state; 26 | } 27 | } 28 | } 29 | store = createStoreWithMiddleware(reducer, initialState); 30 | }); 31 | 32 | beforeEach(() => spy(global.console, 'error')); 33 | afterEach(() => global.console.error.restore()); 34 | 35 | describe('When: An action with analytics meta is dispatched', () => { 36 | 37 | beforeEach(() => store.dispatch({ 38 | type: 'ROUTE_CHANGE', 39 | meta: { 40 | analytics: { type: 'page-load' } 41 | } 42 | })); 43 | 44 | it('Then: It should invoke the tracking callback with the meta as the first argument', () => { 45 | const [ meta ] = eventCallbackSpy.getCall(0).args; 46 | assert.deepEqual(meta, { type: 'page-load' }); 47 | }); 48 | 49 | it('Then: It should invoke the tracking callback with the current state as the second argument', () => { 50 | const [, analyticsState ] = eventCallbackSpy.getCall(0).args; 51 | assert.deepEqual(analyticsState, { name: 'jane smith', loggedIn: false }); 52 | }); 53 | 54 | it('Then: It should only invoke the tracking callback once', () => { 55 | assert.equal(eventCallbackSpy.callCount, 1); 56 | }); 57 | 58 | it('Then: It should not provide an error to the console', () => { 59 | assert.equal(global.console.error.callCount, 0); 60 | }); 61 | 62 | }); 63 | 64 | describe('When: An action with analytics meta is dispatched that updates state', () => { 65 | 66 | beforeEach(() => store.dispatch({ 67 | type: 'LOGIN', 68 | meta: { 69 | analytics: { type: 'foobar' } 70 | } 71 | })); 72 | 73 | it('Then: The new state should be available to the analytics middleware callback', () => { 74 | const [, analyticsState ] = eventCallbackSpy.getCall(0).args; 75 | assert.deepEqual(analyticsState, { name: 'jane smith', loggedIn: true }); 76 | }); 77 | 78 | }); 79 | 80 | describe('When: An action without meta is dispatched', () => { 81 | 82 | beforeEach(() => store.dispatch({ 83 | type: 'SOME_OTHER_EVENT' 84 | })); 85 | 86 | it('Then: It should not invoke the tracking callback', () => { 87 | assert.equal(eventCallbackSpy.callCount, 0); 88 | }); 89 | 90 | it('Then: It should not provide an error to the console', () => { 91 | assert.equal(global.console.error.callCount, 0); 92 | }); 93 | 94 | }); 95 | 96 | describe('When: An action with meta is dispatched, but the meta does not contain an analytics object', () => { 97 | 98 | beforeEach(() => store.dispatch({ 99 | type: 'SOME_OTHER_EVENT', 100 | meta: { not: 'analytics' } 101 | })); 102 | 103 | it('Then: It should not invoke the tracking callback', () => { 104 | assert.equal(eventCallbackSpy.callCount, 0); 105 | }); 106 | 107 | it('Then: It should not provide an error to the console', () => { 108 | assert.equal(global.console.error.callCount, 0); 109 | }); 110 | 111 | }); 112 | 113 | describe('When: An action with meta is dispatched, but the meta is not a Flux Standard Action', () => { 114 | 115 | beforeEach(() => store.dispatch({ 116 | type: 'SOME_OTHER_EVENT', 117 | meta: { 118 | analytics: { 119 | not: 'a flux standard action' 120 | } 121 | } 122 | })); 123 | 124 | it('Then: It should not invoke the tracking callback', () => { 125 | assert.equal(eventCallbackSpy.callCount, 0); 126 | }); 127 | 128 | it('Then: It should provide an error to the console', () => { 129 | assert.deepEqual(global.console.error.getCall(0).args, [ 130 | "The following event wasn't tracked because it isn't a Flux Standard Action (https://github.com/acdlite/flux-standard-action)", 131 | { not: 'a flux standard action' } 132 | ]); 133 | }); 134 | 135 | }); 136 | 137 | }); 138 | 139 | describe('Given: A Store with analytics middleware and a custom meta selector', () => { 140 | 141 | let store; 142 | let selectorSpy; 143 | let eventCallbackSpy; 144 | 145 | beforeEach(() => { 146 | eventCallbackSpy = spy(); 147 | selectorSpy = spy(); 148 | const selector = (...args) => { 149 | selectorSpy(...args); 150 | 151 | const { meta } = args[0]; 152 | return meta.foobar; 153 | }; 154 | const createStoreWithMiddleware = applyMiddleware(analytics(eventCallbackSpy, selector))(createStore); 155 | const initialState = { name: 'jane smith', loggedIn: false }; 156 | const reducer = (state = initialState, action) => { 157 | switch (action.type) { 158 | case 'LOGIN': { 159 | return { 160 | ...state, 161 | loggedIn: true 162 | }; 163 | } 164 | 165 | default: { 166 | return state; 167 | } 168 | } 169 | } 170 | store = createStoreWithMiddleware(reducer, initialState); 171 | }); 172 | 173 | beforeEach(() => spy(global.console, 'error')); 174 | afterEach(() => global.console.error.restore()); 175 | 176 | describe('When: An action with custom analytics meta is dispatched', () => { 177 | 178 | beforeEach(() => store.dispatch({ 179 | type: 'ROUTE_CHANGE', 180 | meta: { 181 | foobar: { type: 'page-load' } 182 | } 183 | })); 184 | 185 | it('Then: It should invoke the selector with the action', () => { 186 | const [ action ] = selectorSpy.getCall(0).args; 187 | assert.deepEqual(action, { 188 | type: 'ROUTE_CHANGE', 189 | meta: { 190 | foobar: { type: 'page-load' } 191 | } 192 | }); 193 | }); 194 | 195 | it('Then: It should invoke the tracking callback with the meta as the first argument', () => { 196 | const [ meta ] = eventCallbackSpy.getCall(0).args; 197 | assert.deepEqual(meta, { type: 'page-load' }); 198 | }); 199 | 200 | }); 201 | 202 | describe('When: An action without meta is dispatched', () => { 203 | 204 | beforeEach(() => store.dispatch({ 205 | type: 'SOME_OTHER_EVENT' 206 | })); 207 | 208 | it('Then: It should not invoke the selector', () => { 209 | assert.equal(selectorSpy.callCount, 0); 210 | }); 211 | 212 | it('Then: It should not invoke the tracking callback', () => { 213 | assert.equal(eventCallbackSpy.callCount, 0); 214 | }); 215 | 216 | }); 217 | 218 | describe('When: An action with custom meta is dispatched, but the meta is not a Flux Standard Action', () => { 219 | 220 | beforeEach(() => store.dispatch({ 221 | type: 'SOME_OTHER_EVENT', 222 | meta: { 223 | foobar: { 224 | not: 'a flux standard action' 225 | } 226 | } 227 | })); 228 | 229 | it('Then: It should not invoke the tracking callback', () => { 230 | assert.equal(eventCallbackSpy.callCount, 0); 231 | }); 232 | 233 | it('Then: It should provide an error to the console', () => { 234 | assert.deepEqual(global.console.error.getCall(0).args, [ 235 | "The following event wasn't tracked because it isn't a Flux Standard Action (https://github.com/acdlite/flux-standard-action)", 236 | { not: 'a flux standard action' } 237 | ]); 238 | }); 239 | 240 | }); 241 | 242 | }); 243 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel/register 2 | --------------------------------------------------------------------------------