diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass
index 8ce962a8d..d55eda872 100644
--- a/app/styles/editor/campaign/campaign-editor-view.sass
+++ b/app/styles/editor/campaign/campaign-editor-view.sass
@@ -21,3 +21,12 @@
     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-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade
index 2515ed484..aa2d23855 100644
--- a/app/templates/editor/campaign/campaign-editor-view.jade
+++ b/app/templates/editor/campaign/campaign-editor-view.jade
@@ -40,5 +40,40 @@ block outer_content
     #right-column
       #campaign-view
       #campaign-level-view.hidden
+      if campaignDropOffs
+        button.btn.btn-default#analytics-button(title="Analytics",  data-toggle="modal" data-target="#analytics-modal") Analytics
+        .modal.fade#analytics-modal(tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true")
+          .modal-dialog
+            .modal-content
+              .modal-header
+                button.close(type="button", data-dismiss="modal", aria-label="Close")
+                  span(aria-hidden="true") ×
+                h4.modal-title Analytics
+              .modal-body
+                if campaignDropOffs.startDay
+                  if campaignDropOffs.endDay
+                    div #{campaignDropOffs.startDay} to #{campaignDropOffs.endDay}
+                  else
+                    div #{campaignDropOffs.startDay} to today
+                table.table.table-bordered.table-condensed.table-hover(style='font-size:10pt')
+                  thead
+                    tr
+                      td Level
+                      td Started
+                      td Dropped
+                      td Drop %
+                      td Finished
+                      td Dropped
+                      td Drop %
+                  tbody
+                    - for (var i = 0; i < campaignDropOffs.levels.length; i++)
+                      tr
+                        td= campaignDropOffs.levels[i].level
+                        td= campaignDropOffs.levels[i].started
+                        td= campaignDropOffs.levels[i].startDropped
+                        td= campaignDropOffs.levels[i].startDropRate
+                        td= campaignDropOffs.levels[i].finished
+                        td= campaignDropOffs.levels[i].finishDropped
+                        td= campaignDropOffs.levels[i].finishDropRate
 
 block footer
diff --git a/app/views/editor/campaign/CampaignEditorView.coffee b/app/views/editor/campaign/CampaignEditorView.coffee
index 2253d0cb2..4e857851b 100644
--- a/app/views/editor/campaign/CampaignEditorView.coffee
+++ b/app/views/editor/campaign/CampaignEditorView.coffee
@@ -47,6 +47,8 @@ module.exports = class CampaignEditorView extends RootView
     @listenToOnce @levels, 'sync', @onFundamentalLoaded
     @listenToOnce @achievements, 'sync', @onFundamentalLoaded
 
+    _.delay @getCampaignDropOffs, 1000
+
   loadThangTypeNames: ->
     # Load the names of the ThangTypes that this level's Treema nodes might want to display.
     originals = []
@@ -130,6 +132,7 @@ module.exports = class CampaignEditorView extends RootView
   getRenderData: ->
     c = super()
     c.campaign = @campaign
+    c.campaignDropOffs = @campaignDropOffs
     c
 
   onClickSaveButton: ->
@@ -236,6 +239,35 @@ module.exports = class CampaignEditorView extends RootView
       achievement.set 'rewards', newRewards
       if achievement.hasLocalChanges()
         @toSave.add achievement
+        
+  getCampaignDropOffs: =>
+    # Fetch last 7 days of campaign drop-off rates
+
+    startDay = new Date()
+    startDay.setDate(startDay.getUTCDate() - 6)
+    startDay = startDay.getUTCFullYear() + '-' + (startDay.getUTCMonth() + 1) + '-' + startDay.getUTCDate()
+
+    success = (data) =>
+      return if @destroyed
+      # API returns all the campaign data currently
+      @campaignDropOffs = data[@campaignHandle]
+      mapFn = (item) -> 
+        item.startDropRate = (item.startDropped / item.started * 100).toFixed(2)
+        item.finishDropRate = (item.finishDropped / item.finished * 100).toFixed(2)
+        item
+      @campaignDropOffs.levels = _.map @campaignDropOffs.levels, mapFn, @
+      @campaignDropOffs.startDay = startDay
+      @render()
+
+    # TODO: Why do we need this url dash?
+    request = @supermodel.addRequestResource 'campaign_drop_offs', {
+      url: '/db/analytics_log_event/-/campaign_drop_offs'
+      data: {startDay: startDay, slugs: [@campaignHandle]}
+      method: 'POST'
+      success: success
+    }, 0
+    request.load()
+
 
 class LevelsNode extends TreemaObjectNode
   valueClass: 'treema-levels'
diff --git a/server/analytics/analytics_log_event_handler.coffee b/server/analytics/analytics_log_event_handler.coffee
index afdb6068b..8115a5fcf 100644
--- a/server/analytics/analytics_log_event_handler.coffee
+++ b/server/analytics/analytics_log_event_handler.coffee
@@ -20,6 +20,7 @@ class AnalyticsLogEventHandler extends Handler
 
   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) ->
@@ -29,7 +30,7 @@ class AnalyticsLogEventHandler extends Handler
     # startDay - Inclusive, optional, e.g. '2014-12-14'
     # endDay - Exclusive, optional, e.g. '2014-12-16'
 
-    # TODO: An uncached call takes about 15s
+    # TODO: An uncached call takes about 15s locally
 
     levelSlugs = req.query.slugs or req.body.slugs
     startDay = req.query.startDay or req.body.startDay
@@ -42,7 +43,7 @@ class AnalyticsLogEventHandler extends Handler
     @levelCompletionsCachedSince ?= new Date()
     if (new Date()) - @levelCompletionsCachedSince > 86400 * 1000  # Dumb cache expiration
       @levelCompletionsCache = {}
-      @levelCompletionsCacheSince = new Date()
+      @levelCompletionsCachedSince = new Date()
     cacheKey = levelSlugs.join(',')
     cacheKey += 's' + startDay if startDay?
     cacheKey += 'e' + endDay if endDay?
@@ -90,4 +91,203 @@ class AnalyticsLogEventHandler extends Handler
       @levelCompletionsCache[cacheKey] = completions
       @sendSuccess res, completions
 
+  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()
diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee
index 23562cbc4..03879c0c7 100644
--- a/server/levels/level_handler.coffee
+++ b/server/levels/level_handler.coffee
@@ -322,7 +322,7 @@ LevelHandler = class LevelHandler extends Handler
     @playCountCachedSince ?= new Date()
     if (new Date()) - @playCountCachedSince > 86400 * 1000  # Dumb cache expiration
       @playCountCache = {}
-      @playCountCacheSince = new Date()
+      @playCountCachedSince = new Date()
     cacheKey = levelIDs.join ','
     if playCounts = @playCountCache[cacheKey]
       return @sendSuccess res, playCounts
@@ -349,7 +349,7 @@ LevelHandler = class LevelHandler extends Handler
     # startDay - Inclusive, optional, e.g. '2014-12-14'
     # endDay - Exclusive, optional, e.g. '2014-12-16'
 
-    # TODO: An uncached call takes about 20s for dungeons-of-kithgard locally
+    # TODO: An uncached call takes about 5s for dungeons-of-kithgard locally
     # TODO: This is very similar to getLevelCompletionsBySlugs(), time to generalize analytics APIs?
 
     levelSlugs = req.query.slugs or req.body.slugs
@@ -363,7 +363,7 @@ LevelHandler = class LevelHandler extends Handler
     @levelPlaytimesCachedSince ?= new Date()
     if (new Date()) - @levelPlaytimesCachedSince > 86400 * 1000  # Dumb cache expiration
       @levelPlaytimesCache = {}
-      @levelPlaytimesCacheSince = new Date()
+      @levelPlaytimesCachedSince = new Date()
     cacheKey = levelSlugs.join(',')
     cacheKey += 's' + startDay if startDay?
     cacheKey += 'e' + endDay if endDay?