codecombat/server/analytics/analytics_log_event_handler.coffee
Matt Lott 2d410fa57f Update editor analytics level completions
We have to grab all the level data at once, so we should cache it all
too.  Only the first level completions call should be uncached/slow.
2015-01-01 12:26:19 -08:00

297 lines
10 KiB
CoffeeScript

AnalyticsLogEvent = require './AnalyticsLogEvent'
Handler = require '../commons/Handler'
log = require 'winston'
class AnalyticsLogEventHandler extends Handler
modelClass: AnalyticsLogEvent
jsonSchema: require '../../app/schemas/models/analytics_log_event'
editableProperties: [
'event'
'properties'
]
hasAccess: (req) ->
req.method in ['POST'] or req.user?.isAdmin()
makeNewInstance: (req) ->
instance = super(req)
instance.set('user', req.user._id)
instance
getByRelationship: (req, res, args...) ->
return @getLevelCompletionsBySlugs(req, res) if args[1] is 'level_completions'
return @getCampaignDropOffs(req, res) if args[1] is 'campaign_drop_offs'
super(arguments...)
getLevelCompletionsBySlugs: (req, res) ->
# Returns an array of per-day level starts and finishes
# Parameters:
# slug - level slug
# startDay - Inclusive, optional, e.g. '2014-12-14'
# endDay - Exclusive, optional, e.g. '2014-12-16'
# TODO: An uncached call takes about 15s locally
levelSlug = req.query.slug or req.body.slug
startDay = req.query.startDay or req.body.startDay
endDay = req.query.endDay or req.body.endDay
return @sendSuccess res, [] unless levelSlug?
# Cache results for 1 day
@levelCompletionsCache ?= {}
@levelCompletionsCachedSince ?= new Date()
if (new Date()) - @levelCompletionsCachedSince > 86400 * 1000 # Dumb cache expiration
@levelCompletionsCache = {}
@levelCompletionsCachedSince = new Date()
cacheKey = levelSlug
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
return @sendSuccess res, levelCompletions if levelCompletions = @levelCompletionsCache[cacheKey]
# Build query
match = {$match: {$and: [{$or: [{"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}]}}
match["$match"]["$and"].push created: {$gte: new Date(startDay + "T00:00:00.000Z")} if startDay?
match["$match"]["$and"].push created: {$lt: new Date(endDay + "T00:00:00.000Z")} if endDay?
project = {"$project": {"_id": 0, "event": 1, "level": {$ifNull: ["$properties.level", "$properties.levelID"]}, "created": {"$concat": [{"$substr": ["$created", 0, 4]}, "-", {"$substr": ["$created", 5, 2]}, "-", {"$substr" : ["$created", 8, 2]}]}}}
group = {"$group": {"_id": {"event": "$event", "created": "$created", "level": "$level"}, "count": {"$sum": 1}}}
query = AnalyticsLogEvent.aggregate match, project, group
query.exec (err, data) =>
if err? then return @sendDatabaseError res, err
# Build per-level-day started and finished counts
levelDateMap = {}
for item in data
created = item._id.created
event = item._id.event
level = item._id.level
continue unless level?
# 'Started Level' event uses level slug, 'Saw Victory' event uses level name with caps and spaces.
level = level.toLowerCase().replace new RegExp(' ', 'g'), '-' if event is 'Saw Victory'
levelDateMap[level] ?= {}
levelDateMap[level][created] ?= {}
levelDateMap[level][created] ?= {}
if event is 'Saw Victory'
levelDateMap[level][created]['finished'] = item.count
else
levelDateMap[level][created]['started'] = item.count
# Build list of level completions
# Cache every level, since we had to grab all this data anyway
completions = {}
for level of levelDateMap
completions[level] = []
for created, item of levelDateMap[level]
completions[level].push
level: level
created: created
started: item.started
finished: item.finished
cacheKey = level
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
@levelCompletionsCache[cacheKey] = completions[level]
@sendSuccess res, completions[levelSlug]
getCampaignDropOffs: (req, res) ->
# Returns a dictionary of per-campaign level start and finish drop-offs
# Drop-off: last started or finished level event
# Parameters:
# slugs - array of campaign slugs
# startDay - Inclusive, optional, e.g. '2014-12-14'
# endDay - Exclusive, optional, e.g. '2014-12-16'
# TODO: Read per-campaign level progression data from a legit source
# TODO: An uncached call can take over 30s locally
# TODO: Returns all the campaigns
# TODO: Calculate overall campaign stats
campaignSlugs = req.query.slugs or req.body.slugs
startDay = req.query.startDay or req.body.startDay
endDay = req.query.endDay or req.body.endDay
return @sendSuccess res, [] unless campaignSlugs?
# Cache results for 1 day
@campaignDropOffsCache ?= {}
@campaignDropOffsCachedSince ?= new Date()
if (new Date()) - @campaignDropOffsCachedSince > 86400 * 1000 # Dumb cache expiration
@campaignDropOffsCache = {}
@campaignDropOffsCachedSince = new Date()
cacheKey = campaignSlugs.join(',')
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
return @sendSuccess res, campaignDropOffs if campaignDropOffs = @campaignDropOffsCache[cacheKey]
queryParams = {$and: [{$or: [ {"event" : 'Started Level'}, {"event" : 'Saw Victory'}]}]}
queryParams["$and"].push created: {$gte: new Date(startDay + "T00:00:00.000Z")} if startDay?
queryParams["$and"].push created: {$lt: new Date(endDay + "T00:00:00.000Z")} if endDay?
AnalyticsLogEvent.find(queryParams).select('created event properties user').exec (err, data) =>
if err? then return @sendDatabaseError res, err
# Bucketize events by user
userProgression = {}
for item in data
created = item.get('created')
event = item.get('event')
if event is 'Saw Victory'
level = item.get('properties.level').toLowerCase().replace new RegExp(' ', 'g'), '-'
else
level = item.get('properties.levelID')
continue unless level?
user = item.get('user')
userProgression[user] ?= []
userProgression[user].push
created: created
event: event
level: level
# Order user progression by created
for user in userProgression
userProgression[user].sort (a,b) -> if a.created < b.created then return -1 else 1
# Per-level start/drop/finish/drop
levelProgression = {}
for user of userProgression
for i in [0...userProgression[user].length]
event = userProgression[user][i].event
level = userProgression[user][i].level
levelProgression[level] ?=
started: 0
startDropped: 0
finished: 0
finishDropped: 0
if event is 'Started Level'
levelProgression[level].started++
levelProgression[level].startDropped++ if i is userProgression[user].length - 1
else if event is 'Saw Victory'
levelProgression[level].finished++
levelProgression[level].finishDropped++ if i is userProgression[user].length - 1
# Put in campaign order
campaignRates = {}
for level of levelProgression
for campaign of campaigns
if level in campaigns[campaign]
started = levelProgression[level].started
startDropped = levelProgression[level].startDropped
finished = levelProgression[level].finished
finishDropped = levelProgression[level].finishDropped
campaignRates[campaign] ?=
levels: []
# overall:
# started: 0,
# startDropped: 0,
# finished: 0,
# finishDropped: 0
campaignRates[campaign].levels.push
level: level
started: started
startDropped: startDropped
finished: finished
finishDropped: finishDropped
break
# Sort level data by campaign order
for campaign of campaignRates
campaignRates[campaign].levels.sort (a, b) ->
if campaigns[campaign].indexOf(a.level) < campaigns[campaign].indexOf(b.level) then return -1 else 1
# Return all campaign data for simplicity
# Cache other individual campaigns too, since we have them
@campaignDropOffsCache[cacheKey] = campaignRates
for campaign of campaignRates
cacheKey = campaign
cacheKey += 's' + startDay if startDay?
cacheKey += 'e' + endDay if endDay?
@campaignDropOffsCache[cacheKey] = campaignRates
@sendSuccess res, campaignRates
# Copied from WorldMapView
dungeonLevels = [
'dungeons-of-kithgard',
'gems-in-the-deep',
'shadow-guard',
'kounter-kithwise',
'crawlways-of-kithgard',
'forgetful-gemsmith',
'true-names',
'favorable-odds',
'the-raised-sword',
'haunted-kithmaze',
'riddling-kithmaze',
'descending-further',
'the-second-kithmaze',
'dread-door',
'known-enemy',
'master-of-names',
'lowly-kithmen',
'closing-the-distance',
'tactical-strike',
'the-final-kithmaze',
'the-gauntlet',
'kithgard-gates',
'cavern-survival'
];
forestLevels = [
'defense-of-plainswood',
'winding-trail',
'patrol-buster',
'endangered-burl',
'village-guard',
'thornbush-farm',
'back-to-back',
'ogre-encampment',
'woodland-cleaver',
'shield-rush',
'peasant-protection',
'munchkin-swarm',
'munchkin-harvest',
'swift-dagger',
'shrapnel',
'arcane-ally',
'touch-of-death',
'bonemender',
'coinucopia',
'copper-meadows',
'drop-the-flag',
'deadly-pursuit',
'rich-forager',
'siege-of-stonehold',
'multiplayer-treasure-grove',
'dueling-grounds'
];
desertLevels = [
'the-dunes',
'the-mighty-sand-yak',
'oasis',
'sarven-road',
'sarven-gaps',
'thunderhooves',
'medical-attention',
'minesweeper',
'sarven-sentry',
'keeping-time',
'hoarding-gold',
'decoy-drill',
'yakstraction',
'sarven-brawl',
'desert-combat',
'dust',
'mirage-maker',
'sarven-savior',
'odd-sandstorm'
];
campaigns = {
'dungeon': dungeonLevels,
'forest': forestLevels,
'desert': desertLevels
}
module.exports = new AnalyticsLogEventHandler()