From e3abb9ceb37b1f33fe14c8f969c7532fad5382e9 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Thu, 4 Dec 2014 12:57:57 -0800 Subject: [PATCH] Capture active user metrics Watching for these events: Level completed User registered Playtime of 30s in a level Purchase Payment Subscribe Earned an achievement --- .../models/analytics_users_active.coffee | 16 ++++ server/achievements/EarnedAchievement.coffee | 2 + server/analytics/AnalyticsUsersActive.coffee | 10 +++ .../analytics_users_active_handler.coffee | 16 ++++ server/commons/mapping.coffee | 1 + server/levels/sessions/LevelSession.coffee | 24 ++++-- server/payments/payment_handler.coffee | 5 +- server/payments/subscription_handler.coffee | 5 +- server/purchases/purchase_handler.coffee | 7 +- server/users/User.coffee | 35 ++++++++- server/users/user_handler.coffee | 2 +- test/server/common.coffee | 1 + test/server/functional/user.spec.coffee | 2 +- test/server/unit/analytics.spec.coffee | 74 +++++++++++++++++++ 14 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 app/schemas/models/analytics_users_active.coffee create mode 100644 server/analytics/AnalyticsUsersActive.coffee create mode 100644 server/analytics/analytics_users_active_handler.coffee create mode 100644 test/server/unit/analytics.spec.coffee diff --git a/app/schemas/models/analytics_users_active.coffee b/app/schemas/models/analytics_users_active.coffee new file mode 100644 index 000000000..f3c6e6a80 --- /dev/null +++ b/app/schemas/models/analytics_users_active.coffee @@ -0,0 +1,16 @@ +c = require './../schemas' + +AnalyticsUsersActiveSchema = c.object { + title: 'Analytics Users Active' + description: 'Active users data.' +} + +_.extend AnalyticsUsersActiveSchema.properties, + creator: c.objectId(links: [{rel: 'extra', href: '/db/user/{($)}'}]) + created: c.date({title: 'Created', readOnly: true}) + + event: {type: 'string'} + +c.extendBasicProperties AnalyticsUsersActiveSchema, 'analytics.users.active' + +module.exports = AnalyticsUsersActiveSchema diff --git a/server/achievements/EarnedAchievement.coffee b/server/achievements/EarnedAchievement.coffee index de4b981c7..53e3679e3 100644 --- a/server/achievements/EarnedAchievement.coffee +++ b/server/achievements/EarnedAchievement.coffee @@ -71,4 +71,6 @@ EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, origin earnedPoints = worth wrapUp(doc) + User.saveActiveUser userID, "achievement" + module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema) diff --git a/server/analytics/AnalyticsUsersActive.coffee b/server/analytics/AnalyticsUsersActive.coffee new file mode 100644 index 000000000..95614335d --- /dev/null +++ b/server/analytics/AnalyticsUsersActive.coffee @@ -0,0 +1,10 @@ +mongoose = require 'mongoose' +plugins = require '../plugins/plugins' + +AnalyticsUsersActiveSchema = new mongoose.Schema({ + created: + type: Date + 'default': Date.now +}, {strict: false}) + +module.exports = AnalyticsUsersActive = mongoose.model('analytics.users.active', AnalyticsUsersActiveSchema) diff --git a/server/analytics/analytics_users_active_handler.coffee b/server/analytics/analytics_users_active_handler.coffee new file mode 100644 index 000000000..c223a62c7 --- /dev/null +++ b/server/analytics/analytics_users_active_handler.coffee @@ -0,0 +1,16 @@ +AnalyticsUsersActive = require './AnalyticsUsersActive' +Handler = require '../commons/Handler' + +class AnalyticsUsersActiveHandler extends Handler + modelClass: AnalyticsUsersActive + jsonSchema: require '../../app/schemas/models/analytics_users_active' + + hasAccess: (req) -> + req.method in ['GET'] or req.user?.isAdmin() + + makeNewInstance: (req) -> + instance = super(req) + instance.set('creator', req.user._id) + instance + +module.exports = new AnalyticsUsersActiveHandler() diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index cd2326af3..dc3b728f8 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -1,4 +1,5 @@ module.exports.handlers = + 'analytics_users_active': 'analytics/analytics_users_active_handler' 'article': 'articles/article_handler' 'level': 'levels/level_handler' 'level_component': 'levels/components/level_component_handler' diff --git a/server/levels/sessions/LevelSession.coffee b/server/levels/sessions/LevelSession.coffee index 46beecef2..22306c3c0 100644 --- a/server/levels/sessions/LevelSession.coffee +++ b/server/levels/sessions/LevelSession.coffee @@ -18,22 +18,34 @@ previous = {} LevelSessionSchema.post 'init', (doc) -> previous[doc.get 'id'] = - 'state.completed': doc.get 'state.completed' + 'state.complete': doc.get 'state.complete' + 'playtime': doc.get 'playtime' LevelSessionSchema.pre 'save', (next) -> + User = require '../../users/User' # Avoid mutual inclusion cycles @set('changed', new Date()) id = @get('id') initd = id of previous + levelID = @get('levelID') + userID = @get('creator') + activeUserEvent = null - # newly completed level - if not (initd and previous[id]['state.completed']) and @get('state.completed') - User = require '../../users/User' # Avoid mutual inclusion cycles - User.update {_id: @get 'creator'}, {$inc: 'stats.gamesCompleted': 1}, {}, (err, count) -> + # Newly completed level + if not (initd and previous[id]['state']?['complete']) and @get('state.complete') + User.update {_id: userID}, {$inc: 'stats.gamesCompleted': 1}, {}, (err, count) -> log.error err if err? + activeUserEvent = "level-completed/#{levelID}" + + # Spent at least 30s playing this level + if not initd and @get('playtime') >= 30 or initd and (@get('playtime') - previous[id]['playtime'] >= 30) + activeUserEvent = "level-playtime/#{levelID}" delete previous[id] if initd - next() + if activeUserEvent? + User.saveActiveUser userID, activeUserEvent, next + else + next() LevelSessionSchema.statics.privateProperties = ['code', 'submittedCode', 'unsubscribed'] LevelSessionSchema.statics.editableProperties = ['multiplayer', 'players', 'code', 'codeLanguage', 'completed', 'state', diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index e72a45d8f..9d17db898 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -80,10 +80,13 @@ PaymentHandler = class PaymentHandler extends Handler @logPaymentError(req, 'Missing apple transaction id') return @sendBadInputError(res, 'Apple purchase? Need to specify which transaction.') @handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice) - + @onPostSuccess req else @handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken) + @onPostSuccess req + onPostSuccess: (req) -> + req.user?.saveActiveUser 'payment' #- Apple payments diff --git a/server/payments/subscription_handler.coffee b/server/payments/subscription_handler.coffee index b4e473ffa..e82fcb9e3 100644 --- a/server/payments/subscription_handler.coffee +++ b/server/payments/subscription_handler.coffee @@ -103,7 +103,6 @@ class SubscriptionHandler extends Handler @updateUser(req, user, customer.subscriptions.data[0], true, done) - updateUser: (req, user, subscription, increment, done) -> stripeInfo = _.cloneDeep(user.get('stripe') ? {}) stripeInfo.planID = 'basic' @@ -123,9 +122,9 @@ class SubscriptionHandler extends Handler if err @logSubscriptionError(req, 'Stripe user plan saving error. '+err) return done({res: 'Database error.', code: 500}) + req.user?.saveActiveUser 'subscribe' return done() - unsubscribeUser: (req, user, done) -> stripeInfo = _.cloneDeep(user.get('stripe')) stripe.customers.cancelSubscription stripeInfo.customerID, stripeInfo.subscriptionID, { at_period_end: true }, (err) => @@ -139,7 +138,7 @@ class SubscriptionHandler extends Handler if err @logSubscriptionError(req, 'User save unsubscribe error. '+err) return done({res: 'Database error.', code: 500}) + req.user?.saveActiveUser 'unsubscribe' return done() - module.exports = new SubscriptionHandler() diff --git a/server/purchases/purchase_handler.coffee b/server/purchases/purchase_handler.coffee index 0b50b8439..cb00a4a67 100644 --- a/server/purchases/purchase_handler.coffee +++ b/server/purchases/purchase_handler.coffee @@ -54,9 +54,10 @@ PurchaseHandler = class PurchaseHandler extends Handler else super(req, res) - + onPostSuccess: (req) -> @addPurchaseToUser(req) + req.user?.saveActiveUser 'purchase' addPurchaseToUser: (req) -> user = req.user @@ -80,7 +81,7 @@ PurchaseHandler = class PurchaseHandler extends Handler spent = hadSpent = user.get('spent') ? 0 spent += item.get('gems') user.set('spent', spent) - + user.save() - + module.exports = new PurchaseHandler() diff --git a/server/users/User.coffee b/server/users/User.coffee index 2e13943fa..cd9d50381 100644 --- a/server/users/User.coffee +++ b/server/users/User.coffee @@ -5,6 +5,7 @@ crypto = require 'crypto' mail = require '../commons/mail' log = require 'winston' plugins = require '../plugins/plugins' +AnalyticsUsersActive = require '../analytics/AnalyticsUsersActive' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) @@ -162,7 +163,7 @@ UserSchema.statics.unconflictName = unconflictName = (name, done) -> UserSchema.methods.register = (done) -> @set('anonymous', false) - @set('permissions', ['admin']) if not isProduction + @set('permissions', ['admin']) if not isProduction and not GLOBAL.testing if (name = @get 'name')? and name isnt '' unconflictName name, (err, uniqueName) => return done err if err @@ -176,6 +177,38 @@ UserSchema.methods.register = (done) -> sendwithus.api.send data, (err, result) -> log.error "sendwithus post-save error: #{err}, result: #{result}" if err delighted.addDelightedUser @ + @saveActiveUser 'register' + +UserSchema.statics.saveActiveUser = (id, event, done=null) -> + id = mongoose.Types.ObjectId id if _.isString id + @findById id, (err, user) -> + if err? + log.error err + else + user?.saveActiveUser event + done?() + +UserSchema.methods.saveActiveUser = (event, done=null) -> + try + return done?() if @isAdmin() + userID = @get('_id') + + # Create if no active user entry for today + today = new Date() + minDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())) + AnalyticsUsersActive.findOne({created: {$gte: minDate}, creator: mongoose.Types.ObjectId(userID)}).exec (err, activeUser) -> + if err? + log.error "saveActiveUser error retrieving active users: #{err}" + else if not activeUser + newActiveUser = new AnalyticsUsersActive() + newActiveUser.set 'creator', userID + newActiveUser.set 'event', event + newActiveUser.save (err) -> + log.error "Level session saveActiveUser error saving active user: #{err}" if err? + done?() + catch err + log.error err + done?() UserSchema.pre('save', (next) -> @set('emailLower', @get('email')?.toLowerCase()) diff --git a/server/users/user_handler.coffee b/server/users/user_handler.coffee index af5b8f137..083b91ab4 100644 --- a/server/users/user_handler.coffee +++ b/server/users/user_handler.coffee @@ -552,7 +552,7 @@ UserHandler = class UserHandler extends Handler usersTotal += 1 userID = user.get('_id').toHexString() - LevelSession.count {creator: userID, 'state.completed': true}, (err, count) -> + LevelSession.count {creator: userID, 'state.complete': true}, (err, count) -> update = if count then {$set: 'stats.gamesCompleted': count} else {$unset: 'stats.gamesCompleted': ''} User.findByIdAndUpdate user.get('_id'), update, doneWithUser diff --git a/test/server/common.coffee b/test/server/common.coffee index 66fe5f40e..a1a894f32 100644 --- a/test/server/common.coffee +++ b/test/server/common.coffee @@ -24,6 +24,7 @@ GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work # _.str = require 'underscore.string' models_path = [ + '../../server/analytics/AnalyticsUsersActive' '../../server/articles/Article' '../../server/levels/Level' '../../server/levels/components/LevelComponent' diff --git a/test/server/functional/user.spec.coffee b/test/server/functional/user.spec.coffee index 7335d47e7..e78b53f43 100644 --- a/test/server/functional/user.spec.coffee +++ b/test/server/functional/user.spec.coffee @@ -342,7 +342,7 @@ describe 'Statistics', -> session = new LevelSession name: 'Beat Gandalf' permissions: simplePermissions - state: completed: true + state: complete: true unittest.getNormalJoe (joe) -> expect(joe.get 'stats.gamesCompleted').toBeUndefined() diff --git a/test/server/unit/analytics.spec.coffee b/test/server/unit/analytics.spec.coffee new file mode 100644 index 000000000..b051c352f --- /dev/null +++ b/test/server/unit/analytics.spec.coffee @@ -0,0 +1,74 @@ +GLOBAL._ = require 'lodash' + +require '../common' +request = require 'request' +AnalyticsUsersActive = require '../../../server/analytics/AnalyticsUsersActive' +LevelSession = require '../../../server/levels/sessions/LevelSession' +User = require '../../../server/users/User' + +# TODO: these tests have some rerun/cleanup issues +# TODO: add tests for purchase, payment, subscribe, unsubscribe, and earned achievements + +describe 'Analytics', -> + + it 'registered user', (done) -> + clearModels [AnalyticsUsersActive], (err) -> + expect(err).toBeNull() + user = new User + permissions: [] + name: "Fred" + Math.floor(Math.random() * 10000) + user.save (err) -> + expect(err).toBeNull() + userID = mongoose.Types.ObjectId(user.get('_id')) + AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) -> + expect(activeUsers.length).toEqual(0) + user.register -> + AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) -> + expect(err).toBeNull() + expect(activeUsers.length).toEqual(1) + expect(activeUsers[0]?.get('event')).toEqual('register') + done() + + it 'level completed', (done) -> + clearModels [AnalyticsUsersActive], (err) -> + expect(err).toBeNull() + unittest.getNormalJoe (joe) -> + userID = mongoose.Types.ObjectId(joe.get('_id')) + session = new LevelSession + name: 'Beat Gandalf' + levelID: 'lotr' + permissions: simplePermissions + state: complete: false + creator: userID + session.save (err) -> + expect(err).toBeNull() + AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) -> + expect(activeUsers.length).toEqual(0) + session.set 'state', complete: true + session.save (err) -> + expect(err).toBeNull() + AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) -> + expect(err).toBeNull() + expect(activeUsers.length).toEqual(1) + expect(activeUsers[0]?.get('event')).toEqual('level-completed/lotr') + done() + + it 'level playtime', (done) -> + clearModels [AnalyticsUsersActive], (err) -> + expect(err).toBeNull() + unittest.getNormalJoe (joe) -> + userID = mongoose.Types.ObjectId(joe.get('_id')) + session = new LevelSession + name: 'Beat Gandalf' + levelID: 'lotr' + permissions: simplePermissions + playtime: 60 + creator: userID + session.save (err) -> + expect(err).toBeNull() + AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) -> + expect(err).toBeNull() + expect(activeUsers.length).toEqual(1) + expect(activeUsers[0]?.get('event')).toEqual('level-playtime/lotr') + done() +