diff --git a/README.md b/README.md index b913299..5733e2a 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Use npm to install! ### Initialization ```javascript -var keen = require('keen.io'); +var Keen = require('keen.io'); // Configure instance. Only projectId and writeKey are required to send data. -var keen = keen.configure({ +var keen = Keen.configure({ projectId: "", writeKey: "", readKey: "", @@ -31,20 +31,20 @@ var keen = keen.configure({ You can also have multiple instances if you are connecting to multiple KeenIO accounts in the one project (probably edge case). ```javascript -var keen = require('keen.io'); +var Keen = require('keen.io'); // Configure instance with API Key -var keen1 = keen.configure({...}); -var keen2 = keen.configure({...}); +var keen1 = Keen.configure({...}); +var keen2 = Keen.configure({...}); ``` In the future there will be the ability to pass options into the initialisation such as batching inserts, etc. The structure of this hasn't been defined yet but will look something like the following. ```javascript -var keen = require('keen.io'); +var Keen = require('keen.io'); // Configure instance with API Key and options -var keen = keen.configure({ +var keen = Keen.configure({ projectId: "", batchEventInserts: 30 }); @@ -53,8 +53,8 @@ var keen = keen.configure({ ### Send Events ```javascript -var keen = require("keen.io"); -var keen = keen.configure({ +var Keen = require("keen.io"); +var keen = Keen.configure({ projectId: "", writeKey: "" }); @@ -82,10 +82,11 @@ keen.addEvents({ ``` ### Generate Scoped Key + ```javascript -var keen = require("keen.io"); +var Keen = require("keen.io"); var apiKey = "YOUR_API_KEY"; -var scopedKey = keen.encryptScopedKey(apiKey, { +var scopedKey = Keen.encryptScopedKey(apiKey, { "allowed_operations": ["read"], "filters": [{ "property_name": "account.id", @@ -93,15 +94,136 @@ var scopedKey = keen.encryptScopedKey(apiKey, { "property_value": "123" }] }); -var keen = keen.configure({ +var keen = Keen.configure({ projectId: ""; readKey: scopedKey }); ``` +## Queries + +Analyses are first-class citizens, complete with parameter getters and setters. + +The `.run` method is available on each configured client instance to run one or many analyses on a given project. Read more about running multiple analyses below. + +**Format:** + +``` +var your_analysis = new Keen.Query(analysisType, params); +``` + +### Example Usage + +``` +var Keen = require('keen.io'); +var keen = Keen.configure({ + projectId: "your_project_id", + readKey: "your_read_key" +}); + +var count = new Keen.Query("count", { + event_collection: "pageviews", + group_by: "property", + timeframe: "this_7_days" +}); + +// Send query +keen.run(count, function(err, response){ + if (err) return console.log(err); + // response.result +}); +``` + + +### Query Analysis Types + +All of the following analyses require an `event_collection` parameter. Some analyses have additional requirements, which are noted below. + +`count` + +`count_unique` + +`sum` requires a `target_property` parameter, where value is an integer + +`average` requires a `target_property` parameter, where value is an integer + +`maximum` requires a `target_property` parameter, where value is an integer + +`minimum` requires a `target_property` parameter, where value is an integer + +`select_unique` requires a `target_property` parameter + +`extraction` + +**A note about extractions:** supply an optional `email` attribute to be notified when your extraction is ready for download. If email is not specified, your extraction will be processed synchronously and your data will be returned as JSON. + +`Keen.Funnel` requires a `steps` attribute + +**A note about funnels:** funnels require a `steps` as an array of objects. Each step requires an `event_collection` and `actor_property` parameter. + +``` +var funfunfunnel = new Keen.Query('funnel', { + steps: [ + { + event_collection: "view_landing_page", + actor_property: "user.id" + }, + { + event_collection: "signed_up", + actor_property: "user.id" + }, + ], + timeframe: "this_6_months" +}); +``` + + +Learn more about funnels in the [API reference](https://keen.io/docs/data-analysis/funnels/#steps) + +### Run multiple analyses at once + +The `.run` method accepts an individual analysis or array of analyses. In the latter scenario, the callback is fired once all requests have completed without error. Query results are then returned in a correctly sequenced array. + +Query results are also attached to the query object itself, and can be referenced as `this.data`. + +``` +var avg_revenue = new Keen.Query("average", { + event_collection: "purchase", + target_property: "price", + group_by: "geo.country" +}); +var max_revenue = new Keen.Query("maximum", { + event_collection: "purchase", + target_property: "price", + group_by: "geo.country" +}); + +var mashup = keen.run([avg_revenue, max_revenue], function(err, res){ + if (err) return console.log(err); + // res[0].result or this.data[0] (avg_revenue) + // res[1].result or this.data[1] (max_revenue) +}); +``` + + +### Get/Set Parameters and Refresh Queries + +``` +// Based on previous example + +// Update parameters +avg_revenue.set({ timeframe: "this_21_days" }); +max_revenue.set({ timeframe: "this_21_days" }); + +// Re-run the query +mashup.refresh(); +``` + + + ## Future Updates -Future module updates are planned to introduce the remaining API calls. You can see some of the spec for that in [examples/queries.js](https://github.com/keenlabs/KeenClient-Node/blob/master/examples/queries.js). Also, as mentioned above, specifying options when creating an instance to configure the behaviour of the instance (ie, batching event submissions). +Future module updates are planned to introduce the remaining API calls. You can see some sketches for these in the [examples directory](https://github.com/keenlabs/KeenClient-Node/blob/master/examples/). Also, as mentioned above, specifying options when creating an instance to configure the behaviour of the instance (ie, batching event submissions). ## Contributing diff --git a/index.js b/index.js index bbaf4f8..6d0969d 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,9 @@ var _ = require('underscore'); var crypto = require('crypto'); var qs = require('querystring'); +var KeenRequests = require('./lib/requests'); +var KeenQuery = require('./lib/query'); + function KeenApi(config) { if (!config) { throw new Error("The 'config' parameter must be specified and must be a JS object."); @@ -188,6 +191,8 @@ function KeenApi(config) { request.get(self.readKey, path, params, callback); } }; + + this.run = KeenQuery.client.run; } function configure(config) { @@ -219,5 +224,6 @@ function decryptScopedKey(apiKey, scopedKey) { module.exports = { configure: configure, encryptScopedKey: encryptScopedKey, - decryptScopedKey: decryptScopedKey + decryptScopedKey: decryptScopedKey, + Query: KeenQuery.Query }; diff --git a/lib/query.js b/lib/query.js new file mode 100644 index 0000000..2c160e5 --- /dev/null +++ b/lib/query.js @@ -0,0 +1,134 @@ +var _ = require('underscore'); +var KeenRequests = require('./requests'); + +/*! +* ----------------- +* Keen IO Query JS +* ----------------- +*/ + +var Keen = {}; + +// ------------------------------ +// Keen.Request +// ------------------------------ + +Keen.Request = function(){ + this.data = {}; + this.configure.apply(this, arguments); +} + +Keen.Request.prototype.configure = function(client, queries, callback){ + this.client = client; + this.queries = queries; + this.callback = callback; + this.run(); + return this; +}; + +Keen.Request.prototype.run = function(){ + var self = this, + completions = 0, + response = []; + + var handleResponse = function(err, res){ + if (err && self.callback) { + return self.callback(err, null); + } + response[arguments[2]] = res, completions++; + if (completions == self.queries.length) { + self.data = (self.queries.length == 1) ? response[0] : response; + if (self.callback) self.callback(null, self.data); + } + }; + + _.each(self.queries, function(query, index){ + var data, path = '/projects/' + self.client.projectId; + var callbackSequencer = function(err, res){ + handleResponse(err, res, index); + }; + + if (query instanceof Keen.Query) { + path += query.path; + data = query.params || {}; + } + /* TODO: Test and deploy this + else if (_.isString(query)) { + path += '/saved_queries/' + query + '/result'; + data = { api_key: self.client.readKey }; + }*/ + else { + throw new Error('Query #' + (index+1) +' is not valid'); + + } + + KeenRequests.get.call(self.client, self.client.readKey, path, data, callbackSequencer); + }); + + return self; +}; + + +// ------------------------------ +// Keen.Query +// ------------------------------ + +Keen.Query = function(){ + this.configure.apply(this, arguments); +}; + +Keen.Query.prototype.configure = function(analysisType, params){ + //if (!collection) throw new Error('Event Collection name is required'); + var self = this; + self.path = '/queries/' + analysisType; + self.params = {}; + self.set(params); + return self; +}; + +Keen.Query.prototype.get = function(attribute) { + if (this.params) { + return this.params[attribute] || null; + } +}; + +Keen.Query.prototype.set = function(attributes) { + var self = this; + _.each(attributes, function(v, k){ + var key = k, value = v; + if (k.match(new RegExp("[A-Z]"))) { + key = k.replace(/([A-Z])/g, function($1) { return "_"+$1.toLowerCase(); }); + } + self.params[key] = value; + + if (_.isArray(value)) { + _.each(value, function(dv, index){ + if (_.isObject(dv)) { + _.each(dv, function(deepValue, deepKey){ + if (deepKey.match(new RegExp("[A-Z]"))) { + var _deepKey = deepKey.replace(/([A-Z])/g, function($1) { return "_"+$1.toLowerCase(); }); + delete self.params[key][index][deepKey]; + self.params[key][index][_deepKey] = deepValue; + } + }); + } + }); + } + + }); + return self; +}; + + +// Export Methods +// ------------------------------ +module.exports = { + client: { + run: function(query, callback){ + if (!query) throw new Error('At least one query is required'); + var queries = (_.isArray(query)) ? query : [query]; + return new Keen.Request(this, queries, callback); + } + }, + Query: Keen.Query +}; diff --git a/lib/requests.js b/lib/requests.js new file mode 100644 index 0000000..ed5bd15 --- /dev/null +++ b/lib/requests.js @@ -0,0 +1,65 @@ +var rest = require('superagent'); +var crypto = require('crypto'); + +// Handle logic of processing response, including error messages +// The error handling should be strengthened over time to be more +// meaningful and robust +// --------------------------------------------------------------- + +function processResponse(err, res, callback) { + callback = callback || function() {}; + + if (res && !res.ok && !err) { + var is_err = res.body && res.body.error_code; + err = new Error(is_err ? res.body.message : 'Unknown error occurred'); + err.code = is_err ? res.body.error_code : 'UnknownError'; + } + + if (err) return callback(err); + return callback(null, res.body); +} + +function buildQueryString(params){ + var query = []; + for (var key in params) { + if (params[key]) { + var value = params[key]; + if (Object.prototype.toString.call(value) !== '[object String]') { + value = JSON.stringify(value); + } + value = encodeURIComponent(value); + query.push(key + '=' + value); + } + } + return "?" + query.join('&'); +}; + +module.exports = { + get: function(apiKey, path, data, callback) { + rest + .get(this.baseUrl + this.apiVersion + path + buildQueryString(data)) + .set('Authorization', apiKey) + .end(function(err, res) { + processResponse(err, res, callback); + }); + }, + post: function(apiKey, path, data, callback) { + rest + .post(this.baseUrl + this.apiVersion + path) + .set('Authorization', apiKey) + .set('Content-Type', 'application/json') + .send(data || {}) + .end(function(err, res) { + processResponse(err, res, callback); + }); + }, + del: function(apiKey, path, callback) { + rest + .del(this.baseUrl + this.apiVersion + path) + .set('Authorization', apiKey) + .set('Content-Length', 0) + .end(function(err, res) { + processResponse(err, res, callback); + }); + } +}; diff --git a/package.json b/package.json index 52922a3..44b84f8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,15 @@ "name": "Daniel Kador", "email": "dan@keen.io", "url": "http://keen.io" + }, + { + "name": "Dustin Larimer", + "email": "dustin@keen.io", + "url": "https://github.com/dustinlarimer" + }, + { + "name": "Bogdan Cirlig", + "url": "https://github.com/bibanul" } ], "main": "index.js", diff --git a/test/test.js b/test/test.js index 1c08e99..2d81ed7 100644 --- a/test/test.js +++ b/test/test.js @@ -1,11 +1,29 @@ /* jshint quotmark:false,indent:4,maxlen:600 */ var should = require("should"); +var _ = require('underscore'); + +var buildQueryString = function(params){ + var query = []; + for (var key in params) { + if (params[key]) { + var value = params[key]; + if (Object.prototype.toString.call(value) !== '[object String]') { + value = JSON.stringify(value); + } + value = encodeURIComponent(value); + query.push(key + '=' + value); + } + } + return "?" + query.join('&'); +}; describe("keen", function() { var keen; + var Keen = require("../"); var projectId = "fakeProjectId"; var writeKey = "fakeWriteKey"; + var readKey = "fakeReadKey"; var nock = require("nock"); beforeEach(function() { @@ -17,6 +35,10 @@ describe("keen", function() { }); }); + afterEach(function() { + nock.cleanAll(); + }); + it("configure should set up client correctly", function() { keen = require("../"); var projectId = "projectId"; @@ -230,4 +252,290 @@ describe("keen", function() { }); }); }); + + + describe('Queries', function() { + + beforeEach(function() { + nock.cleanAll(); + Keen = require("../"); + keen = Keen.configure({ + projectId: projectId, + readKey: readKey + }); + }); + + describe('.run method', function(){ + + it('should be a method', function(){ + keen['run'].should.be.a.Function; + }); + + it('should throw an error when passed an invalid object', function(){ + (function(){ + keen.run(null); + }).should.throwError(); + (function(){ + keen.run({}); + }).should.throwError(); + (function(){ + keen.run(0); + }).should.throwError(); + + // This should be removed when 'saved_query' support is validated + (function(){ + keen.run('string'); + }).should.throwError(); + }); + + }); + + describe('Analysis Types', function(){ + + var analyses = [ + 'count', + 'count_unique', + 'sum', + 'median', + 'percentile', + 'average', + 'minimum', + 'maximum', + 'select_unique', + 'extraction' + ]; + + _.each(analyses, function(type){ + var analysis = new Keen.Query(type, { + eventCollection: 'eventCollection', + timeframe: 'this_7_days' + }); + var query_path = "/3.0/projects/" + projectId + "/queries/" + type; + + it('should be an instance of Keen.Query', function(){ + analysis.should.be.an.instanceOf(Keen.Query); + }); + + it('should have a correct path propery', function(){ + analysis.should.have.property('path'); + analysis.path.should.eql('/queries/' + type); + }); + + it('should have a params property with supplied parameters', function(){ + analysis.should.have.property('params'); + analysis.params.should.have.property('event_collection', 'eventCollection'); + analysis.params.should.have.property('timeframe', 'this_7_days'); + }); + + it('should have a #get method that returns a requested parameter', function(){ + analysis.get.should.be.a.Function; + analysis.get('timeframe').should.eql('this_7_days'); + }); + + it('should have a #set method that sets all supplied properties', function(){ + analysis.set.should.be.a.Function; + analysis.set({ group_by: 'property', target_property: 'referrer' }); + analysis.params.should.have.property('group_by', 'property'); + analysis.params.should.have.property('target_property', 'referrer'); + }); + + it('should #set under_score attributes when camelCase attributes are supplied', function(){ + analysis.set({ groupBy: 'property' }); + analysis.params.should.have.property('group_by', 'property'); + }); + + describe('When handled by .run method', function(){ + + beforeEach(function() { + nock.cleanAll(); + }); + + describe('Single analyses', function(){ + + it('should return a response when successful', function(done){ + var mockResponse = { result: 1 }; + mockGetRequest(query_path, 200, mockResponse); + analysis.params = {}; + var test = keen.run(analysis, function(err, res){ + (err === null).should.be.true; + res.should.eql(mockResponse); + done(); + }); + }); + + it('should return an error when unsuccessful', function(done){ + var mockResponse = { error_code: 'ResourceNotFoundError', message: 'no foo' }; + mockGetRequest(query_path, 500, mockResponse); + analysis.params = {}; + var test = keen.run(analysis, function(err, res){ + err.should.be.an.instanceOf(Error); + err.should.have.property('code', mockResponse.error_code); + done(); + }); + }); + + }); + + + describe('Multiple analyses', function(){ + + it('should return a single response when successful', function(done){ + var mockResponse = { result: 1 }; + mockGetRequest(query_path, 200, mockResponse); + mockGetRequest(query_path, 200, mockResponse); + mockGetRequest(query_path, 200, mockResponse); + analysis.params = {}; + var test = keen.run([analysis, analysis, analysis], function(err, res){ + (err === null).should.be.true; + res.should.be.an.Array; + res.should.have.length(3); + res.should.eql([mockResponse, mockResponse, mockResponse]); + done(); + }); + }); + + it('should return a single error when unsuccessful', function(done){ + var mockResponse = { error_code: 'ResourceNotFoundError', message: 'no foo' }; + mockGetRequest(query_path, 500, mockResponse); + analysis.params = {}; + var test = keen.run([analysis, analysis, analysis], function(err, res){ + err.should.be.an.instanceOf(Error); + err.should.have.property('code', mockResponse.error_code); + done(); + }); + }); + }); + + + }); + + }); + + }); + + + describe('Funnels', function(){ + + var funnel = new Keen.Query('funnel', { + steps: [ + { event_collection: "view_landing_page", actor_property: "user.id" }, + { eventCollection: "sign_up", actorProperty: "user.id" } + ], + timeframe: "this_21_days" + }); + var funnel_path = "/3.0/projects/" + projectId + "/queries/funnel" + buildQueryString(funnel.params); + + it('should be an instance of Keen.Query', function(){ + funnel.should.be.an.instanceOf(Keen.Query); + }); + + it('should have a correct path propery', function(){ + funnel.should.have.property('path'); + funnel.path.should.eql('/queries/funnel'); + }); + + it('should have a params property with supplied parameters', function(){ + funnel.should.have.property('params'); + funnel.params.should.have.property('steps'); + funnel.params.steps.should.be.an.Array.with.lengthOf(2); + }); + + it('should have steps with parameters in proper case', function(){ + funnel.params.steps[1].should.have.property('event_collection'); + funnel.params.steps[1].should.have.property('actor_property'); + }); + + it('should have a #get method that returns a requested parameter', function(){ + funnel.get.should.be.a.Function; + funnel.get('steps').should.be.an.Array; + funnel.get('timeframe').should.eql('this_21_days'); + }); + + it('should have a #set method that sets all supplied properties', function(){ + funnel.set.should.be.a.Function; + funnel.set({ timeframe: 'this_21_days' }); + funnel.params.should.have.property('timeframe', 'this_21_days'); + }); + + it('should #set under_score step attributes when camelCase are supplied ', function(){ + funnel.set({ steps: [ + { eventCollection: "view_landing_page", actorProperty: "user.id" }, + { eventCollection: "sign_up", actorProperty: "user.id" } + ] }); + funnel.params.steps[0].should.have.property('event_collection', 'view_landing_page'); + funnel.params.steps[0].should.have.property('actor_property', 'user.id'); + funnel.params.steps[1].should.have.property('event_collection', 'sign_up'); + funnel.params.steps[1].should.have.property('actor_property', 'user.id'); + }); + + + + describe('When handled by .run method', function(){ + + beforeEach(function() { + nock.cleanAll(); + }); + + describe('Single analyses', function(){ + + it('should return a response when successful', function(done){ + var mockResponse = { result: 1 }; + mockGetRequest(funnel_path, 200, mockResponse); + var test = keen.run(funnel, function(err, res){ + (err === null).should.be.true; + res.should.eql(mockResponse); + done(); + }); + }); + + it('should return an error when unsuccessful', function(done){ + var mockResponse = { error_code: 'ResourceNotFoundError', message: 'no foo' }; + mockGetRequest(funnel_path, 500, mockResponse); + var test = keen.run(funnel, function(err, res){ + err.should.be.an.instanceOf(Error); + err.should.have.property('code', mockResponse.error_code); + done(); + }); + }); + + }); + + + describe('Multiple analyses', function(){ + + it('should return a single response when successful', function(done){ + var mockResponse = { result: 1 }; + mockGetRequest(funnel_path, 200, mockResponse); + mockGetRequest(funnel_path, 200, mockResponse); + mockGetRequest(funnel_path, 200, mockResponse); + var test = keen.run([funnel, funnel, funnel], function(err, res){ + (err === null).should.be.true; + res.should.be.an.Array; + res.should.have.length(3); + res.should.eql([mockResponse, mockResponse, mockResponse]); + done(); + }); + }); + + it('should return a single error when unsuccessful', function(done){ + var mockResponse = { error_code: 'ResourceNotFoundError', message: 'no foo' }; + mockGetRequest(funnel_path, 500, mockResponse); + var test = keen.run([funnel, funnel, funnel], function(err, res){ + err.should.be.an.instanceOf(Error); + err.should.have.property('code', mockResponse.error_code); + done(); + }); + }); + + }); + + + }); + + + + }); + + }); + });