├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── lib └── index.js ├── package.json └── test └── index.tests.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | - 0.12 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Damian Schenkelman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feature-change 2 | 3 | Module to run current and new versions of a feature simultaneously. It helps find differences between results without breaking user applications. 4 | 5 | Once you are sure that there are no more differences you can just remove it. 6 | 7 | This [blog post](https://auth0.com/blog/feature-changes-at-auth0/) explains how it is used at Auth0. 8 | 9 | ## Install 10 | ``` 11 | npm i -S feature-change 12 | ``` 13 | 14 | ## Usage 15 | Let's assume you currently perform searches against mongodb and to improve upon this you want searches to be done in Elastic Search. 16 | ```js 17 | var feature_change = require('feature-change'); 18 | 19 | var current_implementation = mongo_search; 20 | var new_implementation = es_search; 21 | 22 | var options = { 23 | expected: function(cb){ 24 | current_implementation(current_opts, cb); 25 | }, 26 | actual: function(cb){ 27 | new_implementation(es_opts, cb); 28 | }, 29 | logAction: function(mongo_result, es_result){ 30 | // invoked when there is a difference in the results (useful for logging) 31 | } 32 | }; 33 | 34 | feature_change(options, function(err, result){ 35 | // this is the original callback you were using for mongo 36 | // err and result always come from mongo_search 37 | }); 38 | ``` 39 | 40 | ## API 41 | The `feature_change` function takes the following arguments: 42 | * `options` - This parameter should contain the following properties: 43 | * `expected` - A function that takes a `cb` that performs the operation that results in the expected outcome. Commonly, the current implementation of your feature. 44 | * `cb(err, result)` - Takes `err` as the first parameter if an error occurred, otherwise `result` should be passed. 45 | * `actual` - A function that takes a `cb` that performs the operation that results in the actual outcome. Commonly, the new implementation of your feature. 46 | * `cb(err, result)` - Takes `err` as the first parameter if an error occurred, otherwise `result` should be passed. 47 | * `logAction` - A function that takes `expected_result` and `actual_result`. Only invoked if there is an error in `expected` or the results are different. 48 | * `expected_result` - An object that has an `err` property if an error occurred or a `value` if everything was successful. 49 | * `actual_result` - An object that has an `err` property if an error occurred or a `value` if everything was successful. 50 | * `areEqual` - (optional) A function that takes both results and must return a boolean indicating if both results are equal or not. By default the function from module `deep-equal` (https://www.npmjs.com/package/deep-equal) will be used. 51 | * `done` - A function to be invoked with the result of the `expected` operation. This is commonly the callback you were using for the original implementation of the feature. 52 | 53 | ## Implementation details 54 | The `result` or `err` from the `expected` operation is always the one that is provided in `done`. The goal of this module is not to provide one or the other, but to provide a way to find differences in results. 55 | 56 | As soon as the `expected` callback completes `done` will be invoked, even if `actual` has not completed. That is because there is no need to delay the result of the operation. 57 | 58 | ## Contributing 59 | Feel free to open issues with questions/bugs/features. PRs are also welcome. 60 | 61 | ## License 62 | MIT 63 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var deepEqual = require('deep-equal'); 2 | 3 | function handleCallback(results, result_name, logAction, areEqual, err, value){ 4 | if (err) { 5 | results[result_name] = { err: err }; 6 | } else { 7 | results[result_name] = { value: value }; 8 | } 9 | 10 | if (Object.keys(results).length === 2){ 11 | // all results available, but done has been called by handleExpectedCallback 12 | if (typeof results.expected.value !== 'undefined'){ 13 | if (!areEqual(results.expected.value, results.actual.value)){ 14 | return logAction(results.expected, results.actual); 15 | } 16 | } else if (results.expected.err) { 17 | return logAction(results.expected, results.actual); 18 | } 19 | } 20 | } 21 | 22 | function handleExpectedCallback(results, logAction, done, areEqual){ 23 | return function (err, value){ 24 | // we want to make sure the result is returned ASAP 25 | setImmediate(function(){ 26 | handleCallback(results, 'expected', logAction, areEqual, err, value); 27 | }); 28 | 29 | return done(err, value); 30 | }; 31 | } 32 | 33 | /** 34 | * Invokes two implemmentations of the same feature, it compares their results and invokes 35 | * the logAction callback when results are different 36 | * 37 | * @param {object} options This parameter should contain the following properties: 38 | * 39 | * - expected: {Function} it takes a 'cb' that performs the operation that 40 | * results in the expected outcome. Commonly, the current 41 | * implementation of your feature. 42 | * 43 | * - actual: {Function} It takes a cb that performs the operation that 44 | * results in the actual outcome. Commonly, the new 45 | * implementation of your feature. 46 | * 47 | * - logAction: {Function} Only invoked if there is an error in expected or the results 48 | * are different. 49 | * 50 | * - areEqual: {Function} (optional) A function that takes both results and must return 51 | * a boolean indicating if both results are equal or not. 52 | * 53 | * @param {Function} done [It will be invoked with the result of the expected operation. 54 | * This is commonly the callback you were using for the original 55 | * implementation of the feature. 56 | */ 57 | module.exports = function(options, done){ 58 | 59 | if (typeof done !== 'function') { 60 | throw new Error('\'done\' argument must be a function.'); 61 | } 62 | 63 | if (!options || typeof options !== 'object') { 64 | return setImmediate(done, new Error('\'options\' argument is invalid')); 65 | } 66 | 67 | if (typeof options.expected !== 'function') { 68 | return setImmediate(done, new Error('\'options.expected\' argument must be a function')); 69 | } 70 | 71 | if (typeof options.actual !== 'function') { 72 | return setImmediate(done, new Error('\'options.actual\' argument must be a function')); 73 | } 74 | 75 | if (typeof options.logAction !== 'function') { 76 | return setImmediate(done, new Error('\'options.logAction\' argument must be a function')); 77 | } 78 | 79 | if (options.areEqual && typeof options.areEqual !== 'function') { 80 | return setImmediate(done, new Error('\'options.areEqual\' argument should be a function')); 81 | } 82 | 83 | var results = {}; 84 | var areEqual = options.areEqual || deepEqual; 85 | 86 | options.expected(handleExpectedCallback(results, options.logAction, done, areEqual)); 87 | options.actual(handleCallback.bind(null, results, 'actual', options.logAction, areEqual)); 88 | }; 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feature-change", 3 | "version": "2.0.1", 4 | "description": "Module to run current and new versions of a feature simultaneously. It helps find differences between results without breaking user applications.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "author": "Damian Schenkelman", 10 | "license": "MIT", 11 | "dependencies": { 12 | "deep-equal": "^1.0.1" 13 | }, 14 | "devDependencies": { 15 | "chai": "^3.2.0", 16 | "mocha": "^2.3.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/index.tests.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var feature_change = require('../'); 3 | 4 | var noop = function() {}; 5 | 6 | describe('feature change', function(){ 7 | it('should call done when expected completes successfully', function(done){ 8 | 9 | var options = { 10 | expected: function(cb){ 11 | setImmediate(function(){ 12 | cb(null, { success: true }); 13 | }); 14 | }, 15 | actual: function(cb){ 16 | setImmediate(function(){ 17 | cb(new Error('Failed')); 18 | }); 19 | }, 20 | logAction: noop 21 | }; 22 | 23 | feature_change(options, function (err, result) { 24 | expect(err).to.not.exist; 25 | expect(result.success).to.equal(true); 26 | done(); 27 | }); 28 | }); 29 | 30 | it('should call done when expected completes with error', function(done){ 31 | 32 | var options = { 33 | expected: function(cb){ 34 | setImmediate(function(){ 35 | cb(new Error('Failed')); 36 | }); 37 | }, 38 | actual: function(cb){ 39 | setImmediate(function(){ 40 | cb(null, { success: true }); 41 | }); 42 | }, 43 | logAction: noop 44 | }; 45 | 46 | feature_change(options, function (err, result) { 47 | expect(err.message).to.equal('Failed'); 48 | expect(result).to.not.exist; 49 | done(); 50 | }); 51 | }); 52 | 53 | it('should invoke callback as soon as expected completes', function(done){ 54 | var actual_done = false; 55 | var options = { 56 | expected: function(cb){ 57 | setImmediate(function(){ 58 | cb(null, { success: true }); 59 | }); 60 | }, 61 | actual: function(cb){ 62 | setTimeout(function(){ 63 | actual_done = true; 64 | cb(null, { success: true }); 65 | }, 100); 66 | }, 67 | logAction: noop 68 | }; 69 | 70 | feature_change(options, function (err, result) { 71 | expect(actual_done).to.be.false; 72 | done(); 73 | }); 74 | }); 75 | 76 | it('should not invoke log action if expected and actual result are equal', function(done){ 77 | 78 | var options = { 79 | expected: function(cb){ 80 | setImmediate(function(){ 81 | cb(null, { success: true }); 82 | }); 83 | }, 84 | actual: function(cb){ 85 | setTimeout(function(){ 86 | cb(null, { success: true }); 87 | }); 88 | }, 89 | logAction: function() { 90 | done(new Error('Log action was invoked')); 91 | } 92 | }; 93 | 94 | feature_change(options, function (err, result){ 95 | expect(err).to.not.exist; 96 | expect(result.success).to.be.true; 97 | setTimeout(function(){ 98 | done(); 99 | }, 100); 100 | }); 101 | }); 102 | 103 | it('should invoke log action if results are different', function(done){ 104 | 105 | var options = { 106 | expected: function(cb){ 107 | setImmediate(function(){ 108 | cb(null, { success: true }); 109 | }); 110 | }, 111 | actual: function(cb){ 112 | setTimeout(function(){ 113 | cb(null, { success: false }); 114 | }); 115 | }, 116 | logAction: function(expected_result, actual_result){ 117 | expect(expected_result.value.success).to.be.true; 118 | expect(expected_result.err).to.not.exist; 119 | expect(actual_result.value.success).to.be.false; 120 | expect(actual_result.err).to.not.exist; 121 | done(); 122 | } 123 | }; 124 | feature_change(options, function (err, result){ 125 | expect(err).to.not.exist; 126 | expect(result.success).to.be.true; 127 | }); 128 | }); 129 | 130 | it('should invoke log action if expected callback errors', function(done){ 131 | var options = { 132 | expected: function(cb){ 133 | setImmediate(function(){ 134 | cb(new Error('Failed')); 135 | }); 136 | }, 137 | actual: function(cb){ 138 | setTimeout(function(){ 139 | cb(null, { success: false }); 140 | }); 141 | }, 142 | logAction: function(expected_result, actual_result){ 143 | expect(expected_result.value).to.not.exist; 144 | expect(expected_result.err.message).to.equal('Failed'); 145 | expect(actual_result.value.success).to.be.false; 146 | expect(actual_result.err).to.not.exist; 147 | done(); 148 | } 149 | }; 150 | 151 | feature_change(options, function(err, result){ 152 | expect(err.message).to.equal('Failed'); 153 | expect(result).to.not.exist; 154 | }); 155 | }); 156 | 157 | describe('using a custom areEqual function', function () { 158 | 159 | it('should not invoke log action if expected and actual result are equal', function(done){ 160 | var options = { 161 | expected: function(cb){ 162 | setImmediate(function(){ 163 | cb(null, { name: 'a' }); 164 | }); 165 | }, 166 | actual: function(cb){ 167 | setTimeout(function(){ 168 | cb(null, { name: 'A' }); 169 | }); 170 | }, 171 | logAction: function(){ 172 | done(new Error('Log action was invoked')); 173 | }, 174 | areEqual: function (expected_result, actual_result) { 175 | expect(expected_result.name).to.equal('a'); 176 | expect(actual_result.name).to.equal('A'); 177 | return true; 178 | } 179 | }; 180 | 181 | feature_change(options, function (err, result) { 182 | expect(err).to.not.exist; 183 | expect(result.name).to.equal('a'); 184 | setTimeout(function(){ 185 | done(); 186 | }, 100); 187 | }); 188 | }); 189 | 190 | it('should invoke log action if results are different', function(done){ 191 | var options = { 192 | expected: function(cb){ 193 | setImmediate(function(){ 194 | cb(null, { success: true }); 195 | }); 196 | }, 197 | actual: function(cb){ 198 | setTimeout(function(){ 199 | cb(null, { success: true }); 200 | }); 201 | }, 202 | logAction: function (expected_result, actual_result) { 203 | expect(expected_result.value.success).to.be.true; 204 | expect(expected_result.err).to.not.exist; 205 | expect(actual_result.value.success).to.be.true; 206 | expect(actual_result.err).to.not.exist; 207 | done(); 208 | }, 209 | areEqual: function (expected_result, actual_result) { 210 | // validates that both results are the same instance 211 | expect(expected_result === actual_result).to.be.false; 212 | return false; 213 | } 214 | }; 215 | 216 | feature_change(options, function (err, result) { 217 | expect(err).to.not.exist; 218 | expect(result.success).to.be.true; 219 | }); 220 | }); 221 | }); 222 | 223 | describe('validations', function () { 224 | 225 | [ 226 | null, 227 | undefined, 228 | "foo", 229 | true, 230 | 100, 231 | noop 232 | ].forEach(function (options) { 233 | it('should fail if options is not an object instance: ' + JSON.stringify(options), function (done) { 234 | feature_change(options, function (err) { 235 | expect(err).to.exist; 236 | expect(err.message).to.be.equal('\'options\' argument is invalid'); 237 | done(); 238 | }); 239 | }); 240 | }); 241 | 242 | [ 243 | null, 244 | undefined, 245 | "foo", 246 | true, 247 | 100, 248 | {}, 249 | [] 250 | ].forEach(function (expected) { 251 | it('should fail if expected is not a function: ' + JSON.stringify(expected), function (done) { 252 | var options = { 253 | expected: expected 254 | }; 255 | feature_change(options, function (err) { 256 | expect(err).to.exist; 257 | expect(err.message).to.be.equal('\'options.expected\' argument must be a function'); 258 | done(); 259 | }); 260 | }); 261 | }); 262 | 263 | [ 264 | null, 265 | undefined, 266 | "foo", 267 | true, 268 | 100, 269 | {}, 270 | [] 271 | ].forEach(function (actual) { 272 | it('should fail if actual is not a function: ' + JSON.stringify(actual), function (done) { 273 | var options = { 274 | expected: noop, 275 | actual: actual 276 | }; 277 | feature_change(options, function (err) { 278 | expect(err).to.exist; 279 | expect(err.message).to.be.equal('\'options.actual\' argument must be a function'); 280 | done(); 281 | }); 282 | }); 283 | }); 284 | 285 | [ 286 | null, 287 | undefined, 288 | "foo", 289 | true, 290 | 100, 291 | {}, 292 | [] 293 | ].forEach(function (logAction) { 294 | it('should fail if logAction is not a funciton: ' + JSON.stringify(logAction), function (done) { 295 | var options = { 296 | expected: noop, 297 | actual: noop, 298 | logAction: logAction 299 | }; 300 | feature_change(options, function (err) { 301 | expect(err).to.exist; 302 | expect(err.message).to.be.equal('\'options.logAction\' argument must be a function'); 303 | done(); 304 | }); 305 | }); 306 | }); 307 | 308 | 309 | [ 310 | "foo", 311 | true, 312 | 100, 313 | {}, 314 | [] 315 | ].forEach(function (areEqual) { 316 | it('should fail if areEqual is not a function: ' + JSON.stringify(areEqual), function (done) { 317 | var options = { 318 | expected: noop, 319 | actual: noop, 320 | logAction: noop, 321 | areEqual: areEqual 322 | }; 323 | feature_change(options, function (err) { 324 | expect(err).to.exist; 325 | expect(err.message).to.be.equal('\'options.areEqual\' argument should be a function'); 326 | done(); 327 | }); 328 | }); 329 | }); 330 | }); 331 | }); 332 | --------------------------------------------------------------------------------