jenkins-bot has submitted this change and it was merged. Change subject: Refactor search satisfaction to allow reusing code for autocomplete satisfaction ......................................................................
Refactor search satisfaction to allow reusing code for autocomplete satisfaction Change-Id: I1cc59ddcae4ae65c962860caaca5bc00026eeedf --- M modules/ext.wikimediaEvents.searchSatisfaction.js 1 file changed, 265 insertions(+), 205 deletions(-) Approvals: MaxSem: Looks good to me, approved JGirault: Looks good to me, but someone else must approve jenkins-bot: Verified diff --git a/modules/ext.wikimediaEvents.searchSatisfaction.js b/modules/ext.wikimediaEvents.searchSatisfaction.js index 35e3ba3..b8575db 100644 --- a/modules/ext.wikimediaEvents.searchSatisfaction.js +++ b/modules/ext.wikimediaEvents.searchSatisfaction.js @@ -19,241 +19,301 @@ * @author Erik Bernhardson <ebernhard...@wikimedia.org> */ ( function ( mw, $, undefined ) { - + 'use strict'; // reject mobile users - if ( mw.config.get( 'wgMFMode' ) !== null ) { + if ( mw.config.get( 'skin' ) === 'minerva' ) { return; } - var isSearchResultPage = mw.config.get( 'wgIsSearchResultPage' ), + var search, session, + isSearchResultPage = mw.config.get( 'wgIsSearchResultPage' ), uri = new mw.Uri( location.href ), - // wprov attached to all search result links. If available - // indicates user got here directly from Special:Search - wprovPrefix = 'srpw1_', - // srpw1 has the position (including offset) of the search - // result appended. - searchResultPosition = parseInt( uri.query.wprov && - uri.query.wprov.substr( 0, wprovPrefix.length ) === wprovPrefix && - uri.query.wprov.substr( wprovPrefix.length ), 10 ), - cameFromSearchResult = !isNaN( searchResultPosition ), + checkinTimes = [ 10, 20, 30, 40, 50, 60, 90, 120, 150, 180, 210, 240, 300, 360, 420 ], lastScrollTop = 0, - storageNamespace = 'wmE-sS-'; + articleId = mw.config.get( 'wgArticleId' ) + ; + + function extractResultPosition( uri, wprovPrefix ) { + return parseInt( uri.query.wprov && + uri.query.wprov.substr( 0, wprovPrefix.length ) === wprovPrefix && + uri.query.wprov.substr( wprovPrefix.length ), 10 ); + } + + function initFromWprov( wprovPrefix ) { + var res = { + wprovPrefix: wprovPrefix, + resultPosition: extractResultPosition( uri, wprovPrefix ) + }; + res.cameFromSearch = !isNaN( res.resultPosition ); + return res; + } + + search = initFromWprov( 'srpw1_' ); + + // Cleanup the location bar in supported browsers. + if ( uri.query.wprov && window.history.replaceState ) { + delete uri.query.wprov; + window.history.replaceState( {}, '', uri.toString() ); + } + + function SessionState() { + // currently loaded state + var state = {}, + storageNamespace = 'wmE-sS-', + // persistent state keys that have a lifetime + ttl = { + sessionId: 10 * 60 * 1000, + token: 24 * 60 * 60 * 1000 + }, + now = new Date().getTime(); + + /** + * Generates a cache key specific to this session and key type. + * + * @param {string} type + * @return {string} + */ + function key( type ) { + return storageNamespace + '-' + type; + } + + /** + * Generate a unique token. Appends timestamp in base 36 to increase + * uniqueness of the token. + * + * @return {string} + */ + function randomToken() { + return mw.user.generateRandomSessionId() + new Date().getTime().toString( 36 ); + } + + /** + * Initializes the session. + * + * @param {SessionState} session + * @private + */ + function initialize( session ) { + + var sessionId = session.get( 'sessionId' ), + /** + * Determines whether the user is part of the population size. + * + * @param {number} populationSize + * @return {boolean} + * @private + */ + oneIn = function ( populationSize ) { + var rand = mw.user.generateRandomSessionId(), + // take the first 52 bits of the rand value + parsed = parseInt( rand.slice( 0, 13 ), 16 ); + return parsed % populationSize === 0; + }; + + if ( sessionId === 'rejected' ) { + // User was previously rejected + return false; + } + // If a sessionId exists the user was previously accepted into the test + if ( !sessionId ) { + if ( !oneIn( 200 ) ) { + // user was not chosen in a sampling of search results + session.set( 'sessionId', 'rejected' ); + return false; + } + // User was chosen to participate in the test and does not yet + // have a search session id, generate one. + if ( !session.set( 'sessionId', randomToken() ) ) { + return false; + } + } + + if ( session.get( 'token' ) === null ) { + if ( !session.set( 'token', randomToken() ) ) { + return false; + } + } else { + session.refresh( 'token' ); + } + + // Unique token per page load to know which events occured + // within the exact same page. + session.set( 'pageViewId', randomToken() ); + + return true; + } + + this.get = function ( type ) { + if ( !state.hasOwnProperty( type ) ) { + if ( ttl.hasOwnProperty( type ) ) { + var endTime = parseInt( mw.storage.get( key( type + 'EndTime' ) ), 10 ); + if ( endTime && endTime > now ) { + state[ type ] = mw.storage.get( key( type ) ); + } else { + mw.storage.remove( key( type ) ); + mw.storage.remove( key( type + 'EndTime' ) ); + state[ type ] = null; + } + } else { + state[ type ] = null; + } + } + return state[ type ]; + }; + + this.set = function ( type, value ) { + if ( ttl.hasOwnProperty( type ) ) { + if ( !mw.storage.set( key( type + 'EndTime' ), now + ttl[ type ] ) ) { + return false; + } + if ( !mw.storage.set( key( type ), value ) ) { + mw.storage.remove( key( type + 'EndTime' ) ); + return false; + } + } + state[ type ] = value; + return true; + }; + + this.refresh = function ( type ) { + if ( ttl.hasOwnProperty( type ) ) { + mw.storage.set( key( type + 'EndTime' ), now + ttl[ type ] ); + } + }; + + state.enabled = initialize( this ); + + return this; + } /** - * Initializes the test. + * Adds an attribute to the link to track the offset + * of the result in the SERP. * - * @return {boolean} `true` if the user is selected for the test, `false` - * otherwise. + * Expects to be run with an html anchor as `this`. + * + * @param {Event} evt jQuery Event object * @private */ - function initializeTest() { - - var sessionStartTime = mw.storage.get( storageNamespace + 'sessionStartTime' ), - tokenStartTime = mw.storage.get( storageNamespace + 'tokenStartTime' ), - now = new Date().getTime(), - maxSessionLifetimeMs = 10 * 60 * 1000, - maxTokenLifetimeMs = 24 * 60 * 60 * 1000, - searchSessionId, - searchToken, - isSessionValid, - isTokenValid, - /** - * Determines whether the user is part of the population size. - * - * @param {number} populationSize - * @return {boolean} - * @private - */ - oneIn = function ( populationSize ) { - var rand = mw.user.generateRandomSessionId(), - // take the first 52 bits of the rand value - parsed = parseInt( rand.slice( 0, 13 ), 16 ); - return parsed % populationSize === 0; - }; - - // Retrieving values from cache only if they are still valid. - isSessionValid = sessionStartTime && ( now - parseInt( sessionStartTime, 10 ) ) < maxSessionLifetimeMs; - if ( isSessionValid ) { - searchSessionId = mw.storage.get( storageNamespace + 'sessionId' ); + function updateSearchHref( evt ) { + var uri = new mw.Uri( evt.target.href ), + offset = $( evt.target ).data( 'serp-pos' ); + if ( offset ) { + uri.query.wprov = evt.data.wprovPrefix + offset; + evt.target.href = uri.toString(); } - isTokenValid = tokenStartTime && ( now - parseInt( tokenStartTime, 10 ) ) < maxTokenLifetimeMs; - if ( isTokenValid ) { - searchToken = mw.storage.get( storageNamespace + 'token' ); - } - - if ( searchSessionId === 'rejected' ) { - // User was previously rejected - return false; - } - - if ( searchSessionId ) { - // User was previously chosen to participate in the test. - // When a new search is performed reset the session lifetime. - if ( isSearchResultPage ) { - mw.storage.set( storageNamespace + 'sessionStartTime', now ); - } - } else if ( !oneIn( 200 ) ) { - // user was not chosen in a sampling of search results - mw.storage.set( storageNamespace + 'sessionId', 'rejected' ); - mw.storage.set( storageNamespace + 'sessionStartTime', now + maxSessionLifetimeMs ); - return false; - } else { - // User was chosen to participate in the test and does not yet - // have a search session id, generate one. - searchSessionId = mw.user.generateRandomSessionId(); - // If storage is full we can't reliably correlate events from the SERP to the target - // pages. - if ( !mw.storage.set( storageNamespace + 'sessionId', searchSessionId ) || !mw.storage.set( storageNamespace + 'sessionStartTime', now ) - ) { - return false; - } - } - - if ( !searchToken ) { - searchToken = mw.user.generateRandomSessionId(); - if ( !mw.storage.set( storageNamespace + 'token', searchToken ) || !mw.storage.set( storageNamespace + 'tokenStartTime', now ) - ) { - return false; - } - } - - return true; } /** - * Sets up the test. + * Executes an action at the given times. * - * This is assuming the user passed {@link #initializeTest}. + * @param {number[]} checkinTimes Times (in seconds from start) when the + * action should be executed. + * @param {Function} fn The action to execute. + * @private + */ + function interval( checkinTimes, fn ) { + var checkin = checkinTimes.shift(), + timeout = checkin; + + function action() { + var current = checkin; + fn( current ); + + checkin = checkinTimes.shift(); + if ( checkin ) { + timeout = checkin - current; + setTimeout( action, 1000 * timeout ); + } + } + + setTimeout( action, 1000 * timeout ); + } + + function genLogEventFn( session ) { + return function ( action, extraData ) { + var scrollTop = $( window ).scrollTop(), + evt = { + // searchResultPage, visitPage or checkin + action: action, + // identifies a single user performing searches within + // a limited time span. + searchSessionId: session.get( 'sessionId' ), + // used to correlate actions that happen on the same + // page. Otherwise a user opening multiple search results + // in tabs would make their events overlap and the dwell + // time per page uncertain. + pageViewId: session.get( 'pageViewId' ), + // identifies if a user has scrolled the page since the + // last event + scroll: scrollTop !== lastScrollTop, + // identifies a single user over a 24 hour timespan, + // allowing to tie together multiple search sessions + searchToken: session.get( 'token' ) + }; + + lastScrollTop = scrollTop; + + if ( articleId > 0 ) { + evt.articleId = articleId; + } + + // add any schema specific data + if ( extraData ) { + $.extend( evt, extraData ); + } + + // ship the event + mw.loader.using( [ 'schema.TestSearchSatisfaction2' ] ).then( function () { + mw.eventLog.logEvent( 'TestSearchSatisfaction2', evt ); + } ); + }; + } + + /** + * Sets up the full text search test. + * * It will log events and will put an attribute on some links * to track user satisfaction. + * + * @param {SessionState} session */ - function setupTest() { - - var checkinTimes = [ 10, 20, 30, 40, 50, 60, 90, 120, 150, 180, 210, 240, 300, 360, 420 ], - articleId = mw.config.get( 'wgArticleId' ), - searchSessionId = mw.storage.get( storageNamespace + 'sessionId' ), - pageViewId = mw.user.generateRandomSessionId(), - searchToken = mw.storage.get( storageNamespace + 'token' ), - logEvent = function ( action, checkinTime ) { - var scrollTop = $( window ).scrollTop(), - evt = { - // searchResultPage, visitPage or checkin - action: action, - // identifies a single user performing searches within - // a limited time span. - searchSessionId: searchSessionId, - // identifies a single user over a 24 hour timespan, - // allowing to tie together multiple search sessions - searchToken: searchToken, - // used to correlate actions that happen on the same - // page. Otherwise a user opening multiple search results - // in tabs would make their events overlap and the dwell - // time per page uncertain. - pageViewId: pageViewId, - // identifies if a user has scrolled the page since the - // last event - scroll: scrollTop !== lastScrollTop - }; - lastScrollTop = scrollTop; - if ( checkinTime !== undefined ) { - // identifies how long the user has been on this page - evt.checkin = checkinTime; - } - if ( isSearchResultPage ) { - // the users actual search term - evt.query = mw.config.get( 'searchTerm' ); - // the number of results shown on this page. - evt.hitsReturned = $( '.mw-search-result-heading' ).length; - } - if ( articleId > 0 ) { - evt.articleId = articleId; - } - if ( cameFromSearchResult ) { - // this is only available on article pages linked - // directly from a search result. - evt.position = searchResultPosition; - } - mw.loader.using( [ 'schema.TestSearchSatisfaction2' ] ).then( function () { - mw.eventLog.logEvent( 'TestSearchSatisfaction2', evt ); - } ); - }, - /** - * Adds an attribute to the link to track the offset - * of the result in the SERP. - * - * Expects to be run with an html anchor as `this`. - * - * @private - */ - updateSearchHref = function () { - var uri = new mw.Uri( this.href ), - offset = $( this ).data( 'serp-pos' ); - if ( offset ) { - uri.query.wprov = 'srpw1_' + offset; - this.href = uri.toString(); - } - }, - /** - * Executes an action at the given times. - * - * @param {number[]} checkinTimes Times (in seconds from start) when the - * action should be executed. - * @param {Function} fn The action to execute. - * @private - */ - interval = function ( checkinTimes, fn ) { - var checkin = checkinTimes.shift(), - timeout = checkin; - - function action() { - var current = checkin; - fn( current ); - - checkin = checkinTimes.shift(); - if ( checkin ) { - timeout = checkin - current; - setTimeout( action, 1000 * timeout ); - } - } - - setTimeout( action, 1000 * timeout ); - }; + function setupSearchTest( session ) { + var logEvent = genLogEventFn( session ); if ( isSearchResultPage ) { - $( '#mw-content-text' ).on( 'click', '.mw-search-result-heading a', updateSearchHref ); - logEvent( 'searchResultPage' ); - } else if ( cameFromSearchResult ) { - logEvent( 'visitPage' ); - interval( checkinTimes, function ( checkin ) { - logEvent( 'checkin', checkin ); - } ); - } - } + // When a new search is performed reset the session lifetime. + session.refresh( 'sessionId' ); - /** - * Cleanup the location bar in supported browsers. - */ - function cleanupHistoryState() { - if ( window.history.replaceState ) { - delete uri.query.wprov; - window.history.replaceState( {}, '', uri.toString() ); + $( '#mw-content-text' ).on( + 'click', + '.mw-search-result-heading a', + { wprovPrefix: search.wprovPrefix }, + updateSearchHref + ); + logEvent( 'searchResultPage', { + query: mw.config.get( 'searchTerm' ), + hitsReturned: $( '.mw-search-result-heading' ).length + } ); + } else if ( search.cameFromSearch ) { + logEvent( 'visitPage', { + position: search.resultPosition + } ); + interval( checkinTimes, function ( checkin ) { + logEvent( 'checkin', { checkin: checkin } ); + } ); } } /** * Logic starts here. */ - if ( isSearchResultPage || cameFromSearchResult ) { - - if ( cameFromSearchResult ) { - cleanupHistoryState(); - } - + if ( isSearchResultPage || search.cameFromSearch ) { $( document ).ready( function () { - if ( !initializeTest() ) { - return; + session = new SessionState(); + if ( session.get( 'enabled' ) ) { + setupSearchTest( session ); } - setupTest(); } ); } -- To view, visit https://gerrit.wikimedia.org/r/269879 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I1cc59ddcae4ae65c962860caaca5bc00026eeedf Gerrit-PatchSet: 8 Gerrit-Project: mediawiki/extensions/WikimediaEvents Gerrit-Branch: master Gerrit-Owner: EBernhardson <ebernhard...@wikimedia.org> Gerrit-Reviewer: EBernhardson <ebernhard...@wikimedia.org> Gerrit-Reviewer: JGirault <jgira...@wikimedia.org> Gerrit-Reviewer: Jdrewniak <jdrewn...@wikimedia.org> Gerrit-Reviewer: MaxSem <maxsem.w...@gmail.com> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits