Update campaign editor analytics

Adding ‘left game’ counts to overall campaign view.  This is the number
of players that left the game after playing the given level.
This commit is contained in:
Matt Lott 2015-01-18 16:29:44 -08:00
parent 513772d70e
commit 05f028d944
7 changed files with 257 additions and 70 deletions
app
scripts/analytics/mongodb/queries
server

View file

@ -0,0 +1,3 @@
#campaign-analytics-modal
.modal-dialog
width: 75%

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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);

View file

@ -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:

View file

@ -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()