Track how long users are viewing images for

This is complete, but it would be better if the HEAD request
was actually aborted by Varnish when the viewDuration parameter is
present, or if the hit pointed to a script that does that.

Change-Id: I66cafd97427756411e967de1901324af2215e3ae
Mingle: https://wikimedia.mingle.thoughtworks.com/projects/multimedia/cards/1001
This commit is contained in:
Gilles Dubuc 2014-11-21 00:39:29 +01:00
parent c98a243639
commit 0e01796a3c
10 changed files with 314 additions and 52 deletions

View file

@ -117,6 +117,14 @@ if ( !isset( $wgMediaViewerImageQueryParameter ) ) {
$wgMediaViewerImageQueryParameter = false;
}
if ( !isset( $wgMediaViewerRecordViewDuration ) ) {
/**
* If set, record the view duration via a HEAD request.
* @var bool
*/
$wgMediaViewerRecordViewDuration = false;
}
$wgMessagesDirs['MultimediaViewer'] = __DIR__ . '/i18n';
$wgExtensionMessagesFiles['MultimediaViewer'] = __DIR__ . '/MultimediaViewer.i18n.php';
@ -898,6 +906,7 @@ $wgResourceModules += array(
'mmv.routing',
'mmv.logging.DurationLogger',
'mmv.logging.DimensionLogger',
'mmv.logging.ViewLogger',
'jquery.fullscreen',
'jquery.hidpi',
'jquery.scrollTo',
@ -1003,6 +1012,17 @@ $wgResourceModules += array(
),
),
'mmv.logging.ViewLogger' => $wgMediaViewerResourceTemplate + array(
'scripts' => array(
'mmv/logging/mmv.logging.ViewLogger.js',
),
'dependencies' => array(
'mediawiki.Uri',
'mmv.base',
),
),
'mmv.head' => $wgMediaViewerResourceTemplate + array(
'scripts' => array(
'mmv/mmv.head.js',

View file

@ -142,7 +142,9 @@ class MultimediaViewerHooks {
global $wgMediaViewerActionLoggingSamplingFactorMap, $wgNetworkPerformanceSamplingFactor,
$wgMediaViewerDurationLoggingSamplingFactor, $wgMediaViewerDurationLoggingLoggedinSamplingFactor,
$wgMediaViewerAttributionLoggingSamplingFactor, $wgMediaViewerDimensionLoggingSamplingFactor,
$wgMediaViewerIsInBeta, $wgMediaViewerUseThumbnailGuessing, $wgMediaViewerImageQueryParameter;
$wgMediaViewerIsInBeta, $wgMediaViewerUseThumbnailGuessing, $wgMediaViewerImageQueryParameter,
$wgMediaViewerRecordViewDuration;
$vars['wgMultimediaViewer'] = array(
'infoLink' => self::$infoLink,
'discussionLink' => self::$discussionLink,
@ -155,6 +157,7 @@ class MultimediaViewerHooks {
'attributionSamplingFactor' => $wgMediaViewerAttributionLoggingSamplingFactor,
'dimensionSamplingFactor' => $wgMediaViewerDimensionLoggingSamplingFactor,
'imageQueryParameter' => $wgMediaViewerImageQueryParameter,
'recordViewDuration' => $wgMediaViewerRecordViewDuration,
'tooltipDelay' => 1000,
);
$vars['wgMediaViewer'] = true;
@ -199,6 +202,7 @@ class MultimediaViewerHooks {
'tests/qunit/mmv/logging/mmv.logging.ActionLogger.test.js',
'tests/qunit/mmv/logging/mmv.logging.AttributionLogger.test.js',
'tests/qunit/mmv/logging/mmv.logging.DimensionLogger.test.js',
'tests/qunit/mmv/logging/mmv.logging.ViewLogger.test.js',
'tests/qunit/mmv/model/mmv.model.test.js',
'tests/qunit/mmv/model/mmv.model.IwTitle.test.js',
'tests/qunit/mmv/model/mmv.model.TaskQueue.test.js',

View file

@ -0,0 +1,172 @@
/*
* This file is part of the MediaWiki extension MultimediaViewer.
*
* MultimediaViewer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* MultimediaViewer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with MultimediaViewer. If not, see <http://www.gnu.org/licenses/>.
*/
( function ( mw, $ ) {
var VL;
/**
* Tracks how long users are viewing images for
* @class mw.mmv.logging.ViewLogger
* @extends mw.Api
* @constructor
* @param {mw.Map} mwConfig mw.config
* @param {Object} window Browser window object
* @param {mw.mmv.logging.ActionLogger} actionLogger ActionLogger object
*/
function ViewLogger( mwConfig, windowObject, actionLogger ) {
var config = mwConfig && mwConfig.get ? mwConfig.get( 'wgMultimediaViewer' ) : false;
/**
* Was the last image view logged or was logging skipped?
* @property {boolean}
*/
this.wasLastViewLogged = false;
/**
* Record when the user started looking at the current image
* @property {number}
*/
this.viewStartTime = 0;
/**
* How long the user has been looking at the current image
* @property {number}
*/
this.viewDuration = 0;
/**
* The image URL to hit with a HEAD request
* @property {string}
*/
this.url = '';
/**
* Should the view duration be recorded through a HEAD request
* @property {boolean}
*/
this.shouldRecordViewDuration = config ? config.recordViewDuration : false;
/**
* Browser window
* @property {Object}
*/
this.window = windowObject;
/**
* Action logger
* @property {mw.mmv.logging.ActionLogger}
*/
this.actionLogger = actionLogger;
}
VL = ViewLogger.prototype;
/**
* Tracks the unview event of the current image if appropriate
*/
VL.unview = function () {
if ( !this.wasLastViewLogged ) {
return;
}
this.wasLastViewLogged = false;
this.actionLogger.log( 'image-unview', true );
};
/**
* Starts recording a viewing window for the current image
*/
VL.startViewDuration = function () {
this.viewStartTime = $.now();
};
/**
* Stops recording the viewing window for the current image
*/
VL.stopViewDuration = function () {
if ( this.viewStartTime ) {
this.viewDuration += $.now() - this.viewStartTime;
this.viewStartTime = 0;
}
};
/**
* Records the amount of time the current image has been viewed
*/
VL.recordViewDuration = function () {
var uri;
this.stopViewDuration();
if ( this.shouldRecordViewDuration && this.viewDuration > 0 ) {
uri = new mw.Uri( this.url );
uri.extend( { viewDuration: this.viewDuration } );
$.ajax( {
type: 'HEAD',
url: uri.toString()
} );
mw.log( 'Image has been viewed for ', this.viewDuration );
}
this.viewDuration = 0;
this.unview();
};
/**
* Sets up the view tracking for the current image
* @param {string} url URL of the image to send a HEAD request to
*/
VL.attach = function ( url ) {
var view = this;
this.url = url;
this.startViewDuration();
$( this.window )
.off( '.mmv-view-logger' )
.on( 'beforeunload.mmv-view-logger', function() {
view.recordViewDuration();
} )
.on( 'focus.mmv-view-logger', function() {
view.startViewDuration();
} )
.on( 'blur.mmv-view-logger', function() {
view.stopViewDuration();
} );
};
/*
* Stops listening to events
*/
VL.unattach = function() {
$( this.window ).off( '.mmv-view-logger' );
this.stopViewDuration();
};
/**
* Tracks whether or not the image view event was logged or not (i.e. was it in the logging sample)
* @param {boolean} wasEventLogged Whether the image view event was logged
*/
VL.setLastViewLogged = function ( wasEventLogged ) {
this.wasLastViewLogged = wasEventLogged;
};
mw.mmv.logging.ViewLogger = ViewLogger;
}( mediaWiki, jQuery ) );

View file

@ -467,7 +467,7 @@
*/
MMVB.getViewer = function () {
if ( this.viewer === undefined ) {
this.viewer = new mw.mmv.MultimediaViewer();
this.viewer = new mw.mmv.MultimediaViewer( mw.config );
this.viewer.setupEventHandlers();
}

View file

@ -24,10 +24,17 @@
* to manage the viewing experience of such content.
* @class mw.mmv.MultimediaViewer
* @constructor
* @param {mw.Map} mwConfig mw.config
*/
function MultimediaViewer() {
function MultimediaViewer( mwConfig ) {
var apiCacheMaxAge = 86400; // one day
/**
* @property {mw.Map}
* @private
*/
this.mwConfig = mwConfig;
/**
* @property {mw.mmv.provider.Image}
* @private
@ -40,7 +47,7 @@
*/
this.imageInfoProvider = new mw.mmv.provider.ImageInfo( new mw.mmv.logging.Api( 'imageinfo' ),
// Short-circuit, don't fallback, to save some tiny amount of time
{ language: mw.config.get( 'wgUserLanguage', false ) || mw.config.get( 'wgContentLanguage', 'en' ) }
{ language: this.mwConfig.get( 'wgUserLanguage', false ) || this.mwConfig.get( 'wgContentLanguage', 'en' ) }
);
/**
@ -106,10 +113,9 @@
this.documentTitle = document.title;
/**
* Was the last image view logged or was logging skipped?
* @property {boolean}
* @property {mw.mmv.logging.ViewLogger} view -
*/
this.wasLastViewLogged = false;
this.viewLogger = new mw.mmv.logging.ViewLogger( this.mwConfig, window, mw.mmv.actionLogger );
}
MMVP = MultimediaViewer.prototype;
@ -424,14 +430,10 @@
this.ui.canvas.unblur();
}
mw.mmv.actionLogger.log( 'image-view' ).then( function ( wasEventLogged ) {
viewer.wasLastViewLogged = wasEventLogged;
this.viewLogger.attach( thumbnail.url );
if ( viewer.wasLastViewLogged ) {
$( window ).on( 'beforeunload.unview', function() { viewer.unview(); } );
} else {
$( window ).off( 'beforeunload.unview' );
}
mw.mmv.actionLogger.log( 'image-view' ).then( function ( wasEventLogged ) {
viewer.viewLogger.setLastViewLogged( wasEventLogged );
} );
};
@ -797,6 +799,8 @@
var thumb;
if ( index < this.thumbs.length && index >= 0 ) {
this.viewLogger.recordViewDuration();
thumb = this.thumbs[ index ];
this.loadImage( thumb.image, thumb.$thumb.clone()[0] );
}
@ -806,7 +810,6 @@
* Opens the next image
*/
MMVP.nextImage = function () {
this.unview();
mw.mmv.actionLogger.log( 'next-image' );
this.loadIndex( this.currentIndex + 1 );
};
@ -815,7 +818,6 @@
* Opens the previous image
*/
MMVP.prevImage = function () {
this.unview();
mw.mmv.actionLogger.log( 'prev-image' );
this.loadIndex( this.currentIndex - 1 );
};
@ -826,7 +828,8 @@
MMVP.close = function () {
var windowTitle;
this.unview();
this.viewLogger.recordViewDuration();
this.viewLogger.unattach();
windowTitle = this.createDocumentTitle( null );
@ -951,17 +954,5 @@
mw.loader.load( [ 'mmv.ui.reuse.share', 'mmv.ui.reuse.embed', 'mmv.ui.reuse.download', 'moment' ] );
};
/**
* Tracks the unview event of the current image if appropriate
*/
MMVP.unview = function () {
if ( !this.wasLastViewLogged ) {
return;
}
this.wasLastViewLogged = false;
mw.mmv.actionLogger.log( 'image-unview', true );
};
mw.mmv.MultimediaViewer = MultimediaViewer;
}( mediaWiki, jQuery ) );

View file

@ -0,0 +1,75 @@
( function ( mw, $ ) {
QUnit.module( 'mmv.logging.ViewLogger', QUnit.newMwEnvironment( {
setup: function () {
this.clock = this.sandbox.useFakeTimers();
}
} ) );
QUnit.test( 'unview()', 4, function ( assert ) {
var logger = { log : $.noop },
viewLogger = new mw.mmv.logging.ViewLogger( {}, {}, logger );
this.sandbox.stub( logger, 'log' );
viewLogger.unview();
assert.ok( !logger.log.called, 'action logger not called' );
viewLogger.setLastViewLogged( false );
viewLogger.unview();
assert.ok( !logger.log.called, 'action logger not called' );
viewLogger.setLastViewLogged( true );
viewLogger.unview();
assert.ok( logger.log.calledOnce, 'action logger called' );
viewLogger.unview();
assert.ok( logger.log.calledOnce, 'action logger not called again' );
} );
QUnit.test( 'focus and blur', 1, function ( assert ) {
var fakeWindow = $( '<div>' ),
viewLogger = new mw.mmv.logging.ViewLogger( {}, fakeWindow, { log : $.noop } );
this.clock.tick( 1 ); // This is just so that $.now() > 0 in the fake timer environment
viewLogger.attach();
this.clock.tick( 5 );
fakeWindow.triggerHandler( 'blur' );
this.clock.tick( 2 );
fakeWindow.triggerHandler( 'focus' );
this.clock.tick( 3 );
fakeWindow.triggerHandler( 'blur' );
this.clock.tick( 4 );
assert.strictEqual( viewLogger.viewDuration, 8, 'Only focus duration was logged' );
} );
QUnit.test( 'stopViewDuration before startViewDuration', 1, function ( assert ) {
var viewLogger = new mw.mmv.logging.ViewLogger( {}, {}, { log : $.noop } );
this.clock.tick( 1 ); // This is just so that $.now() > 0 in the fake timer environment
viewLogger.stopViewDuration();
this.clock.tick( 2 );
viewLogger.startViewDuration();
this.clock.tick( 3 );
viewLogger.stopViewDuration();
assert.strictEqual( viewLogger.viewDuration, 3, 'Only last timeframe was logged' );
} );
}( mediaWiki, jQuery ) );

View file

@ -275,7 +275,7 @@
var bootstrap,
$div,
$link,
viewer = new mw.mmv.MultimediaViewer(),
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
fname = 'valid',
imgSrc = '/' + fname + '.jpg/300px-' + fname + '.jpg',
imgRegex = new RegExp( imgSrc + '$' );

View file

@ -136,7 +136,7 @@
var buttonOffset, panelBottom,
oldRevealButtonsAndFadeIfNeeded,
lightbox = new mw.mmv.LightboxInterface(),
viewer = new mw.mmv.MultimediaViewer(),
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
oldFnEnterFullscreen = $.fn.enterFullscreen,
oldFnExitFullscreen = $.fn.exitFullscreen;
@ -245,7 +245,7 @@
} );
QUnit.test( 'Keyboard prev/next', 2, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer(),
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
lightbox = new mw.mmv.LightboxInterface();
viewer.setupEventHandlers();

View file

@ -2,7 +2,7 @@
QUnit.module( 'mmv', QUnit.newMwEnvironment() );
QUnit.test( 'eachPrealoadableLightboxIndex()', 11, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer(),
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
expectedIndices,
i;
@ -31,7 +31,7 @@
QUnit.test( 'Hash handling', 7, function ( assert ) {
var oldUnattach,
viewer = new mw.mmv.MultimediaViewer(),
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
ui = new mw.mmv.LightboxInterface(),
imageSrc = 'Foo bar.jpg',
image = { filePageTitle: new mw.Title( 'File:' + imageSrc ) };
@ -102,7 +102,7 @@
QUnit.test( 'Progress', 4, function ( assert ) {
var imageDeferred = $.Deferred(),
viewer = new mw.mmv.MultimediaViewer();
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.thumbs = [];
viewer.displayPlaceholderThumbnail = $.noop;
@ -158,7 +158,7 @@
secondImageDeferred = $.Deferred(),
firstImage = { index: 1, filePageTitle : new mw.Title( 'File:First.jpg' ) },
secondImage = { index: 2, filePageTitle : new mw.Title( 'File:Second.jpg' ) },
viewer = new mw.mmv.MultimediaViewer();
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.thumbs = [];
viewer.displayPlaceholderThumbnail = $.noop;
@ -256,7 +256,7 @@
} );
QUnit.test( 'resetBlurredThumbnailStates', 4, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer();
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
assert.ok( !viewer.realThumbnailShown, 'Real thumbnail state is correct' );
assert.ok( !viewer.blurredThumbnailShown, 'Placeholder state is correct' );
@ -271,7 +271,7 @@
} );
QUnit.test( 'Placeholder first, then real thumbnail', 4, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer();
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.setImage = $.noop;
viewer.ui = { canvas : {
@ -286,14 +286,14 @@
assert.ok( viewer.blurredThumbnailShown, 'Placeholder state is correct' );
assert.ok( !viewer.realThumbnailShown, 'Real thumbnail state is correct' );
viewer.displayRealThumbnail();
viewer.displayRealThumbnail( { url : undefined } );
assert.ok( viewer.realThumbnailShown, 'Real thumbnail state is correct' );
assert.ok( viewer.blurredThumbnailShown, 'Placeholder state is correct' );
} );
QUnit.test( 'Placeholder first, then real thumbnail - missing size', 4, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer();
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.currentIndex = 1;
viewer.setImage = $.noop;
@ -309,14 +309,14 @@
assert.ok( viewer.blurredThumbnailShown, 'Placeholder state is correct' );
assert.ok( !viewer.realThumbnailShown, 'Real thumbnail state is correct' );
viewer.displayRealThumbnail();
viewer.displayRealThumbnail( { url : undefined } );
assert.ok( viewer.realThumbnailShown, 'Real thumbnail state is correct' );
assert.ok( viewer.blurredThumbnailShown, 'Placeholder state is correct' );
} );
QUnit.test( 'Real thumbnail first, then placeholder', 4, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer();
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.setImage = $.noop;
viewer.ui = {
@ -326,7 +326,7 @@
unblur: $.noop
} };
viewer.displayRealThumbnail();
viewer.displayRealThumbnail( { url : undefined } );
assert.ok( viewer.realThumbnailShown, 'Real thumbnail state is correct' );
assert.ok( !viewer.blurredThumbnailShown, 'Placeholder state is correct' );
@ -338,7 +338,7 @@
} );
QUnit.test( 'displayRealThumbnail', 2, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer();
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.setImage = $.noop;
viewer.ui = { canvas : {
@ -348,16 +348,16 @@
viewer.blurredThumbnailShown = true;
// Should not result in an unblurWithAnimation animation (image cache from cache)
viewer.displayRealThumbnail( undefined, undefined, undefined, 5 );
viewer.displayRealThumbnail( { url : undefined }, undefined, undefined, 5 );
assert.ok( !viewer.ui.canvas.unblurWithAnimation.called, 'There should not be an unblurWithAnimation animation' );
// Should result in an unblurWithAnimation (image didn't come from cache)
viewer.displayRealThumbnail( undefined, undefined, undefined, 1000 );
viewer.displayRealThumbnail( { url : undefined }, undefined, undefined, 1000 );
assert.ok( viewer.ui.canvas.unblurWithAnimation.called, 'There should be an unblurWithAnimation animation' );
} );
QUnit.test( 'New image loaded while another one is loading', 5, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer(),
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
firstImageDeferred = $.Deferred(),
secondImageDeferred = $.Deferred(),
firstLigthboxInfoDeferred = $.Deferred(),
@ -419,7 +419,7 @@
QUnit.test( 'Events are not trapped after the viewer is closed', 0, function( assert ) {
var i, j, k, eventParameters,
viewer = new mw.mmv.MultimediaViewer(),
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
$document = $( document ),
$qf = $( '#qunit-fixture' ),
eventTypes = [ 'keydown', 'keyup', 'keypress', 'click', 'mousedown', 'mouseup' ],
@ -490,7 +490,7 @@
} );
QUnit.test( 'Refuse to load too-big thumbnails', 1, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer(),
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
intendedWidth = 50,
title = mw.Title.newFromText( 'File:Foobar.svg' );
@ -507,7 +507,7 @@
thumbnailInfoStub,
imageStub,
promise,
viewer = new mw.mmv.MultimediaViewer(),
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
sandbox = this.sandbox,
oldUseThumbnailGuessing = mw.config.get( 'wgMultimediaViewer' ).useThumbnailGuessing,
file = new mw.Title( 'File:Copyleft.svg' ),
@ -607,7 +607,7 @@
} );
QUnit.test( 'document.title', 2, function ( assert ) {
var viewer = new mw.mmv.MultimediaViewer(),
var viewer = new mw.mmv.MultimediaViewer( { get : $.noop } ),
bootstrap = new mw.mmv.MultimediaViewerBootstrap(),
title = new mw.Title( 'File:This_should_show_up_in_document_title.png'),
oldDocumentTitle = document.title;

View file

@ -22,7 +22,7 @@
var i = 0,
$qf = $( '#qunit-fixture' ),
buttons = new mw.mmv.ui.CanvasButtons( $qf, $( '<div>' ), $( '<div>' ) ),
viewer = new mw.mmv.MultimediaViewer();
viewer = new mw.mmv.MultimediaViewer( { get : $.noop } );
viewer.ui = {};