diff --git a/app/styles/editor/campaign/campaign-analytics-modal.sass b/app/styles/editor/campaign/campaign-analytics-modal.sass new file mode 100644 index 000000000..75e0ee6d9 --- /dev/null +++ b/app/styles/editor/campaign/campaign-analytics-modal.sass @@ -0,0 +1,3 @@ +#campaign-analytics-modal + .modal-dialog + width: 75% diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass index af81785e5..8ce962a8d 100644 --- a/app/styles/editor/campaign/campaign-editor-view.sass +++ b/app/styles/editor/campaign/campaign-editor-view.sass @@ -21,13 +21,3 @@ bottom: 0 right: 0 width: 75% - - #analytics-button - position: absolute - right: 1% - top: 1% - padding: 3px 8px - - #analytics-modal - .modal-content - background-color: white diff --git a/app/templates/editor/campaign/campaign-analytics-modal.jade b/app/templates/editor/campaign/campaign-analytics-modal.jade index a19be9ac9..30b21089c 100644 --- a/app/templates/editor/campaign/campaign-analytics-modal.jade +++ b/app/templates/editor/campaign/campaign-analytics-modal.jade @@ -16,7 +16,10 @@ block modal-body-content td Level td Started td Finished + td Left Game + td LG % td Playtime (s) + td LG/s td Completion % tbody - for (var i = 0; i < campaignCompletions.levels.length; i++) @@ -24,10 +27,19 @@ block modal-body-content td= campaignCompletions.levels[i].level td= campaignCompletions.levels[i].started td= campaignCompletions.levels[i].finished + td= campaignCompletions.levels[i].dropped + if campaignCompletions.top3DropPercentage && campaignCompletions.top3DropPercentage.indexOf(campaignCompletions.levels[i].level) >= 0 + td(style='background-color:pink;')= td= campaignCompletions.levels[i].dropPercentage + else + td= campaignCompletions.levels[i].dropPercentage td= campaignCompletions.levels[i].averagePlaytime - if campaignCompletions.top3.indexOf(campaignCompletions.levels[i].level) >= 0 + if campaignCompletions.top3DropPerSecond && campaignCompletions.top3DropPerSecond.indexOf(campaignCompletions.levels[i].level) >= 0 + td(style='background-color:pink;')= td= campaignCompletions.levels[i].droppedPerSecond + else + td= campaignCompletions.levels[i].droppedPerSecond + if campaignCompletions.top3 && campaignCompletions.top3.indexOf(campaignCompletions.levels[i].level) >= 0 td(style='background-color:lightblue;')= campaignCompletions.levels[i].completionRate - else if campaignCompletions.bottom3.indexOf(campaignCompletions.levels[i].level) >= 0 + else if campaignCompletions.bottom3 && campaignCompletions.bottom3.indexOf(campaignCompletions.levels[i].level) >= 0 td(style='background-color:pink;')= campaignCompletions.levels[i].completionRate else td= campaignCompletions.levels[i].completionRate diff --git a/app/views/editor/campaign/CampaignAnalyticsModal.coffee b/app/views/editor/campaign/CampaignAnalyticsModal.coffee index 0b47bf6b5..f8837b616 100644 --- a/app/views/editor/campaign/CampaignAnalyticsModal.coffee +++ b/app/views/editor/campaign/CampaignAnalyticsModal.coffee @@ -31,47 +31,41 @@ module.exports = class CampaignAnalyticsModal extends ModalView startDay = $('#input-startday').val() endDay = $('#input-endday').val() delete @campaignCompletions.levels + @campaignCompletions.startDay = startDay + @campaignCompletions.endDay = endDay @render() @getCampaignAnalytics startDay, endDay getCampaignAnalytics: (startDay, endDay) => - # Fetch campaign analytics, unless dates given + if startDay? + startDayDashed = startDay + startDay = startDay.replace(/-/g, '') + else + startDay = utils.getUTCDay -14 + startDayDashed = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" + if endDay? + endDayDashed = endDay + endDay = endDay.replace(/-/g, '') + else + endDay = utils.getUTCDay -1 + endDayDashed = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" + @campaignCompletions.startDay = startDayDashed + @campaignCompletions.endDay = endDayDashed - startDay = startDay.replace(/-/g, '') if startDay? - endDay = endDay.replace(/-/g, '') if endDay? + # Chain these together so we can calculate relative metrics (e.g. left game per second) + @getCampaignLevelCompletions startDay, endDay, () => + @render() + @getCompaignLevelDrops startDay, endDay, () => + @render() + @getCampaignAveragePlaytimes startDayDashed, endDayDashed, () => + @render() - startDay ?= utils.getUTCDay -14 - endDay ?= utils.getUTCDay -1 - - success = (data) => - return if @destroyed - mapFn = (item) -> - item.completionRate = (item.finished / item.started * 100).toFixed(2) - item - @campaignCompletions.levels = _.map data, mapFn, @ - sortedLevels = _.cloneDeep @campaignCompletions.levels - sortedLevels = _.filter sortedLevels, ((a) -> a.finished >= 10), @ - sortedLevels.sort (a, b) -> b.completionRate - a.completionRate - @campaignCompletions.top3 = _.pluck sortedLevels[0..2], 'level' - sortedLevels.sort (a, b) -> a.completionRate - b.completionRate - @campaignCompletions.bottom3 = _.pluck sortedLevels[0..2], 'level' - @campaignCompletions.startDay = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" - @campaignCompletions.endDay = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" - @getCampaignAveragePlaytimes startDay, endDay - - # TODO: Why do we need this url dash? - request = @supermodel.addRequestResource 'campaign_completions', { - url: '/db/analytics_perday/-/campaign_completions' - data: {startDay: startDay, endDay: endDay, slug: @campaignHandle} - method: 'POST' - success: success - }, 0 - request.load() - - getCampaignAveragePlaytimes: (startDay, endDay) => + getCampaignAveragePlaytimes: (startDay, endDay, doneCallback) => # Fetch level average playtimes + # Needs date format yyyy-mm-dd success = (data) => return if @destroyed + # console.log 'getCampaignAveragePlaytimes success', data levelAverages = {} for item in data levelAverages[item.level] ?= [] @@ -81,14 +75,16 @@ module.exports = class CampaignAnalyticsModal extends ModalView if levelAverages[level.level].length > 0 total = _.reduce levelAverages[level.level], ((sum, num) -> sum + num) level.averagePlaytime = (total / levelAverages[level.level].length).toFixed(2) + if level.averagePlaytime > 0 and level.dropped > 0 + level.droppedPerSecond = (level.dropped / level.averagePlaytime).toFixed(2) else level.averagePlaytime = 0.0 - @render() - - startDay ?= utils.getUTCDay -14 - startDay = "#{startDay[0..3]}-#{startDay[4..5]}-#{startDay[6..7]}" - endDay ?= utils.getUTCDay -1 - endDay = "#{endDay[0..3]}-#{endDay[4..5]}-#{endDay[6..7]}" + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.droppedPerSecond > 0), @ + sortedLevels.sort (a, b) -> b.droppedPerSecond - a.droppedPerSecond + @campaignCompletions.top3DropPerSecond = _.pluck sortedLevels[0..2], 'level' + doneCallback() levelSlugs = _.pluck @campaignCompletions.levels, 'level' @@ -99,3 +95,62 @@ module.exports = class CampaignAnalyticsModal extends ModalView success: success }, 0 request.load() + + getCampaignLevelCompletions: (startDay, endDay, doneCallback) => + # Needs date format yyyymmdd + success = (data) => + return if @destroyed + # console.log 'getCampaignLevelCompletions success', data + mapFn = (item) -> + item.completionRate = if item.started > 0 then (item.finished / item.started * 100).toFixed(2) else 0.0 + item + @campaignCompletions.levels = _.map data, mapFn, @ + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.finished >= 10), @ + if sortedLevels.length >= 3 + sortedLevels.sort (a, b) -> b.completionRate - a.completionRate + @campaignCompletions.top3 = _.pluck sortedLevels[0..2], 'level' + @campaignCompletions.bottom3 = _.pluck sortedLevels[sortedLevels.length - 4...sortedLevels.length - 1], 'level' + + doneCallback() + + # TODO: Why do we need this url dash? + request = @supermodel.addRequestResource 'campaign_completions', { + url: '/db/analytics_perday/-/campaign_completions' + data: {startDay: startDay, endDay: endDay, slug: @campaignHandle} + method: 'POST' + success: success + }, 0 + request.load() + + getCompaignLevelDrops: (startDay, endDay, doneCallback) => + # Fetch level drops + # Needs date format yyyymmdd + success = (data) => + return if @destroyed + # console.log 'getCompaignLevelDrops success', data + levelDrops = {} + for item in data + levelDrops[item.level] ?= item.dropped + for level in @campaignCompletions.levels + level.dropped = levelDrops[level.level] ? 0 + level.dropPercentage = (level.dropped / level.started * 100).toFixed(2) if level.started > 0 + + sortedLevels = _.cloneDeep @campaignCompletions.levels + sortedLevels = _.filter sortedLevels, ((a) -> a.dropPercentage > 0), @ + if sortedLevels.length >= 3 + sortedLevels.sort (a, b) -> b.dropPercentage - a.dropPercentage + @campaignCompletions.top3DropPercentage = _.pluck sortedLevels[0..2], 'level' + doneCallback() + + return unless @campaignCompletions?.levels? + levelSlugs = _.pluck @campaignCompletions.levels, 'level' + + request = @supermodel.addRequestResource 'level_drops', { + url: '/db/analytics_perday/-/level_drops' + data: {startDay: startDay, endDay: endDay, slugs: levelSlugs} + method: 'POST' + success: success + }, 0 + request.load() diff --git a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js index 86518d90d..fa0128471 100644 --- a/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js +++ b/scripts/analytics/mongodb/queries/insertPerDayAnalytics.js @@ -8,6 +8,8 @@ // Finish count for the same start date is how many unique users finished the remaining steps in the following ~30 days // https://mixpanel.com/help/questions/articles/how-are-funnels-calculated +// Drop count: last started or finished level event for a given unique user + // TODO: Why are Mixpanel level finish events significantly lower? // TODO: dungeons-of-kithgard completion rate is 62% vs. 77% // TODO: Similar start events, finish events off by 20% (5334 vs 6486) @@ -74,11 +76,12 @@ function getLevelFunnelData(startDay, eventFunnel) { var queryParams = {$and: [{_id: {$gte: startObj}},{"event": {$in: eventFunnel}}]}; var cursor = db['analytics.log.events'].find(queryParams); - // Map ordering: level, user, event, created + // Map ordering: level, user, event, day var userDataMap = {}; while (cursor.hasNext()) { var doc = cursor.next(); - var created = doc._id.getTimestamp().toISOString().substring(0, 10); + var created = doc._id.getTimestamp().toISOString(); + var day = created.substring(0, 10); var event = doc.event; var properties = doc.properties; var user = doc.user; @@ -91,13 +94,13 @@ function getLevelFunnelData(startDay, eventFunnel) { if (!userDataMap[level]) userDataMap[level] = {}; if (!userDataMap[level][user]) userDataMap[level][user] = {}; - if (!userDataMap[level][user][event] || userDataMap[level][user][event].localeCompare(created) > 0) { - // if (userDataMap[level][user][event]) log("Found earlier date " + level + " " + event + " " + user + " " + userDataMap[level][user][event] + " " + created); - userDataMap[level][user][event] = created; + if (!userDataMap[level][user][event] || userDataMap[level][user][event].localeCompare(day) > 0) { + // if (userDataMap[level][user][event]) log("Found earlier date " + level + " " + event + " " + user + " " + userDataMap[level][user][event] + " " + day); + userDataMap[level][user][event] = day; } } - // Data: level, created, event + // Data: level, day, event var levelFunnelData = {}; for (level in userDataMap) { for (user in userDataMap[level]) { @@ -105,14 +108,14 @@ function getLevelFunnelData(startDay, eventFunnel) { // Find first event date var funnelStartDay = null; for (event in userDataMap[level][user]) { - var created = userDataMap[level][user][event]; + var day = userDataMap[level][user][event]; if (!levelFunnelData[level]) levelFunnelData[level] = {}; - if (!levelFunnelData[level][created]) levelFunnelData[level][created] = {}; - if (!levelFunnelData[level][created][event]) levelFunnelData[level][created][event] = 0; + if (!levelFunnelData[level][day]) levelFunnelData[level][day] = {}; + if (!levelFunnelData[level][day][event]) levelFunnelData[level][day][event] = 0; if (eventFunnel[0] === event) { // First event gets attributed to current date - levelFunnelData[level][created][event]++; - funnelStartDay = created; + levelFunnelData[level][day][event]++; + funnelStartDay = day; break; } } @@ -135,6 +138,51 @@ function getLevelFunnelData(startDay, eventFunnel) { return levelFunnelData; } +function getLevelDropCounts(startDay, events) { + // How many unique users did one of these events last? + // Return level/day breakdown + + if (!startDay || !events || events.length === 0) return {}; + + var startObj = objectIdWithTimestamp(ISODate(startDay + "T00:00:00.000Z")); + var queryParams = {$and: [{_id: {$gte: startObj}},{"event": {$in: events}}]}; + var cursor = db['analytics.log.events'].find(queryParams); + + var userProgression = {}; + while (cursor.hasNext()) { + var doc = cursor.next(); + var created = doc._id.getTimestamp().toISOString(); + var event = doc.event; + var properties = doc.properties; + var user = doc.user; + var level; + + // TODO: Switch to properties.levelID for 'Saw Victory' + if (event === 'Saw Victory' && properties.level) level = properties.level.toLowerCase().replace(/ /g, '-'); + else if (properties.levelID) level = properties.levelID + else continue + + if (!userProgression[user]) userProgression[user] = []; + userProgression[user].push({ + created: created, + event: event, + level: level + }); + } + + var levelDropCounts = {}; + for (user in userProgression) { + userProgression[user].sort(function (a,b) {return a.created < b.created ? -1 : 1}); + var lastEvent = userProgression[user][userProgression[user].length - 1]; + var level = lastEvent.level; + var day = lastEvent.created.substring(0, 10); + if (!levelDropCounts[level]) levelDropCounts[level] = {}; + if (!levelDropCounts[level][day]) levelDropCounts[level][day] = 0 + levelDropCounts[level][day]++; + } + return levelDropCounts; +} + function insertEventCount(event, level, day, count) { // analytics.perdays schema in server/analytics/AnalyticsPeryDay.coffee day = day.replace(/-/g, ''); @@ -153,7 +201,7 @@ function insertEventCount(event, level, day, count) { // log("Updating count in db for " + day + " " + event + " " + level + " " + doc.c + " => " + count); var results = db['analytics.perdays'].update(queryParams, {$set: {c: count}}); if (results.nMatched !== 1 && results.nModified !== 1) { - log("ERROR: update count failed"); + log("ERROR: update event count failed"); printjson(results); } } @@ -191,13 +239,25 @@ try { log("Inserting aggregated level completion data..."); for (level in levelCompletionData) { - for (created in levelCompletionData[level]) { - if (today === created) continue; // Never save data for today because it's incomplete - for (event in levelCompletionData[level][created]) { - insertEventCount(event, level, created, levelCompletionData[level][created][event]); + for (day in levelCompletionData[level]) { + if (today === day) continue; // Never save data for today because it's incomplete + for (event in levelCompletionData[level][day]) { + insertEventCount(event, level, day, levelCompletionData[level][day][event]); } } } + + log("Getting level drop counts..."); + var levelDropCounts = getLevelDropCounts(startDay, levelCompletionFunnel) + + log("Inserting level drop counts..."); + var levelDropCounts = getLevelDropCounts(startDay, levelCompletionFunnel) + for (level in levelDropCounts) { + for (day in levelDropCounts[level]) { + if (today === day) continue; // Never save data for today because it's incomplete + insertEventCount('User Dropped', level, day, levelDropCounts[level][day]); + } + } } catch(err) { log("ERROR: " + err); diff --git a/server/analytics/analytics_perday_handler.coffee b/server/analytics/analytics_perday_handler.coffee index c5cbde6b8..3f5537e2c 100644 --- a/server/analytics/analytics_perday_handler.coffee +++ b/server/analytics/analytics_perday_handler.coffee @@ -15,10 +15,11 @@ class AnalyticsPerDayHandler extends Handler getByRelationship: (req, res, args...) -> return @getCampaignCompletionsBySlug(req, res) if args[1] is 'campaign_completions' return @getLevelCompletionsBySlug(req, res) if args[1] is 'level_completions' + return @getLevelDropsBySlugs(req, res) if args[1] is 'level_drops' super(arguments...) getCampaignCompletionsBySlug: (req, res) -> - # Send back an array of level starts and finishes + # Send back an ordered array of level starts and finishes # Parameters: # slug - campaign slug # startDay - Inclusive, optional, YYYYMMDD e.g. '20141214' @@ -41,7 +42,7 @@ class AnalyticsPerDayHandler extends Handler cacheKey = campaignSlug cacheKey += 's' + startDay if startDay? cacheKey += 'e' + endDay if endDay? - return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignCompletionsCache[cacheKey] + return @sendSuccess res, completions if completions = @campaignCompletionsCache[cacheKey] getCompletions = (orderedLevelSlugs, levelStringIDSlugMap) => # 3. Send back an array of level starts and finishes @@ -80,8 +81,8 @@ class AnalyticsPerDayHandler extends Handler for levelID of levelEventCounts completions.push level: levelStringIDSlugMap[levelID] - started: levelEventCounts[levelID][startEventID] - finished: levelEventCounts[levelID][finishEventID] + started: levelEventCounts[levelID][startEventID] ? 0 + finished: levelEventCounts[levelID][finishEventID] ? 0 completions.sort (a, b) -> orderedLevelSlugs.indexOf(a.level) - orderedLevelSlugs.indexOf(b.level) @campaignCompletionsCache[cacheKey] = completions @@ -124,6 +125,69 @@ class AnalyticsPerDayHandler extends Handler campaignLevels.push level for level of doc.get('levels') for doc in documents getLevelData campaignLevels + getLevelDropsBySlugs: (req, res) -> + # Send back an array of level/drops + # Drops - Number of unique users for which this was the last level they played + # Parameters: + # slugs - level slugs + # startDay - Inclusive, optional, YYYYMMDD e.g. '20141214' + # endDay - Exclusive, optional, YYYYMMDD e.g. '20141216' + + levelSlugs = req.query.slugs or req.body.slugs + startDay = req.query.startDay or req.body.startDay + endDay = req.query.endDay or req.body.endDay + + # log.warn "level_drops levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}" + + return @sendSuccess res, [] unless levelSlugs? + + # Cache results in app server memory for 1 day + @levelDropsCache ?= {} + @levelDropsCachedSince ?= new Date() + if (new Date()) - @levelDropsCachedSince > 86400 * 1000 + @levelDropsCache = {} + @levelDropsCachedSince = new Date() + cacheKey = levelSlugs.join '' + cacheKey += 's' + startDay if startDay? + cacheKey += 'e' + endDay if endDay? + return @sendSuccess res, drops if drops = @levelDropsCache[cacheKey] + + AnalyticsString.find({v: {$in: ['User Dropped', 'all'].concat(levelSlugs)}}).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelStringIDSlugMap = {} + for doc in documents + droppedEventID = doc._id if doc.v is 'User Dropped' + filterEventID = doc._id if doc.v is 'all' + levelStringIDSlugMap[doc._id] = doc.v if doc.v in levelSlugs + + return @sendSuccess res, [] unless droppedEventID? and filterEventID? + + queryParams = {$and: [ + {e: droppedEventID}, + {f: filterEventID}, + {l: {$in: Object.keys(levelStringIDSlugMap)}} + ]} + queryParams["$and"].push {d: {$gte: startDay}} if startDay? + queryParams["$and"].push {d: {$lt: endDay}} if endDay? + AnalyticsPerDay.find(queryParams).exec (err, documents) => + if err? then return @sendDatabaseError res, err + + levelEventCounts = {} + for doc in documents + levelEventCounts[doc.l] ?= {} + levelEventCounts[doc.l][doc.e] ?= 0 + levelEventCounts[doc.l][doc.e] += doc.c + + drops = [] + for levelID of levelEventCounts + drops.push + level: levelStringIDSlugMap[levelID] + dropped: levelEventCounts[levelID][droppedEventID] ? 0 + + @levelDropsCache[cacheKey] = drops + @sendSuccess res, drops + getLevelCompletionsBySlug: (req, res) -> # Returns an array of per-day starts and finishes for given level # Parameters: diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index 4e86301f5..1831b5b35 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -7,6 +7,7 @@ Handler = require '../commons/Handler' mongoose = require 'mongoose' async = require 'async' utils = require '../lib/utils' +log = require 'winston' LevelHandler = class LevelHandler extends Handler modelClass: Level @@ -363,6 +364,8 @@ LevelHandler = class LevelHandler extends Handler return @sendSuccess res, [] unless levelSlugs? + # log.warn "playtime_averages levelSlugs='#{levelSlugs}' startDay=#{startDay} endDay=#{endDay}" + # Cache results for 1 day @levelPlaytimesCache ?= {} @levelPlaytimesCachedSince ?= new Date()