Replace searchSatisfaction in SearchPreview event implementation

- [x] Create a sessionId replacement
- [x] Set the session ID to be generated after every 10 minute hiatus
- [x] update Unit Tests
- [x] Add new event on extension Load

Bug: T326663
Change-Id: Ice7e8a5d05a74ee7c0912be491d9ba3620802fae
这个提交包含在:
Simone Cuomo 2023-01-18 12:42:07 +00:00 提交者 Matthias Mullie
父节点 d8b0e42956
当前提交 4cdc48fe36
共有 5 个文件被更改,包括 338 次插入68 次删除

查看文件

@ -73,6 +73,7 @@
"mediawiki.api",
"mediawiki.storage",
"mediawiki.ForeignApi",
"mediawiki.storage",
"mediawiki.user",
"jquery.spinner",
"vue",

查看文件

@ -68,6 +68,7 @@ const mw = {
},
storage: {
get: jest.fn(),
set: jest.fn(),
setObject: jest.fn(),
getObject: jest.fn(),
remove: jest.fn()
@ -78,18 +79,22 @@ const mw = {
loadIfNeeded: jest.fn()
},
loader: {
using: jest.fn()
using: jest.fn().mockResolvedValue()
},
user: {
options: {
get: jest.fn().mockReturnValue( 0 ),
set: jest.fn()
},
isAnon: jest.fn().mockReturnValue( false )
isAnon: jest.fn().mockReturnValue( false ),
generateRandomSessionId: jest.fn().mockReturnValue( 'fakeRandomSession' )
},
internalWikiUrlencode: jest.fn( ( value ) => {
return value;
} )
} ),
eventLog: {
submit: jest.fn()
}
};
/*
* MW front-end code expects certain global variables to exist in the

查看文件

@ -54,7 +54,7 @@ module.exports = exports = {
'closeQuickView',
'onPageClose'
] ),
mapActions( 'events', [ 'setQuickViewEventProps' ] ),
mapActions( 'events', [ 'initEventLoggingSession' ] ),
{
setQueryQuickViewTitle: function () {
const mwUri = new mw.Uri();
@ -81,6 +81,7 @@ module.exports = exports = {
return this.getSearchResults().find( `[title="${title}"]` ).closest( 'li' )[ 0 ];
},
leaving() {
this.handleEventTimeout( true );
// Emit QuickView closing event only if QuickView is present in url
if ( this.queryQuickViewTitle ) {
this.onPageClose();
@ -99,10 +100,9 @@ module.exports = exports = {
}
},
created() {
this.setQuickViewEventProps().then( () => {
// This event triggers only if user interacts with the page before closing
window.addEventListener( 'beforeunload', this.leaving );
} );
this.initEventLoggingSession();
// This event triggers only if user interacts with the page before closing
window.addEventListener( 'beforeunload', this.leaving );
},
mounted: function () {

查看文件

@ -1,65 +1,176 @@
const SESSION_STORAGE_KEY = 'searchvue-session-id';
const SESSION_EXPIRATION = 10 * 60;
/**
* Generate a unique token. Appends timestamp in base 36 to increase
* uniqueness of the token.
*
* @return {string}
*/
function randomToken() {
return mw.user.generateRandomSessionId() + Date.now().toString( 36 );
}
/**
* @typedef {Object} StaticEventData
* @property {string} schema
* @property {string} wikiId
* @property {string} platform
* @property {boolean} isAnon
* @property {string} sessionId
*/
/**
* @typedef {Object} VariableEventData
* @property {string} action
* @property {number} selectedIndex
*/
/**
* @typedef {Object} EventLogData
* @property {string} $schema
* @property {string} action
* @property {string} result_display_position
* @property {string} wiki_id
* @property {string} platform
* @property {boolean} is_anon
* @property {string} session_id
*/
/**
* @param {StaticEventData} staticData
* @param {VariableEventData} variableData
* @return {EventLogData}
*/
function createEvent( staticData, variableData ) {
/* eslint-disable camelcase */
return {
$schema: staticData.schema,
action: variableData.action,
result_display_position: variableData.selectedIndex,
wiki_id: staticData.wikiId,
platform: staticData.platform,
is_anon: staticData.isAnon,
session_id: staticData.sessionId
};
/* eslint-enable camelcase */
}
/**
* This method first ensure that the event logging extension is loaded, and then triggers events.
*
* @param {EventLogData} event
* @return {Promise}
*/
function loadEventLoggingAndSendEvent( event ) {
return mw.loader.using( [ 'ext.eventLogging' ] ).then( function () {
mw.eventLog.submit( 'mediawiki.searchpreview', event );
} );
}
const events = {
namespaced: true,
state: () => ( {
sessionId: null,
schema: null,
wikiId: null,
platform: null
sessionId: mw.storage.get( SESSION_STORAGE_KEY ),
schema: '/analytics/mediawiki/searchpreview/3.0.0',
wikiId: mw.config.get( 'wgDBname' ),
platform: mw.config.get( 'skin' ) === 'minerva' ? 'mobile' : 'desktop',
isAnon: mw.user.isAnon()
} ),
mutations: {
SET_QUICK_VIEW_EVENT_PROPS: ( state, { schema, wikiId, platform } ) => {
state.schema = schema;
state.wikiId = wikiId;
state.platform = platform;
},
SET_SESSION_ID: ( state, sessionId ) => {
mw.storage.set( SESSION_STORAGE_KEY, sessionId, SESSION_EXPIRATION );
state.sessionId = sessionId;
}
},
actions: {
/**
* A "search session" comprises all searches happening within
* a certain period of time (10 minutes) after the last search.
*
* It doesn't matter what users had been doing in the meantime -
* whether they initiated new searches, visited other pages,
* interacted or idled on the same search result - until 10
* minutes have passed since initiating the previous search,
* subsequent searches will be considered part of the same session.
*
* After a hiatus of 10 minutes where no new searches have been
* initiated, a new search will be regarded as a new session.
*
* @param {Object} context
* @param {Function} context.commit
* @param {Function} context.dispatch
*/
setQuickViewEventProps: ( context ) => {
const data = {
schema: '/analytics/mediawiki/searchpreview/2.0.0',
wikiId: mw.config.get( 'wgDBname' ),
platform: mw.config.get( 'skin' ) === 'minerva' ? 'mobile' : 'desktop'
};
context.commit( 'SET_QUICK_VIEW_EVENT_PROPS', data );
initEventLoggingSession: ( context ) => {
// If we already have a session, we're fine!
// We just need to commit the existing session id once more;
// since this is another new search, the existing search session
// is to be extended for another 10 minutes, and we need to
// update the TTL of the session ID
if ( context.state.sessionId ) {
context.commit( 'SET_SESSION_ID', context.state.sessionId );
return;
}
context.dispatch( 'startNewEventLoggingSession' );
},
/**
* @param {Object} context
* @param {Object} payload
* @param {string} payload.action
* @param {number} payload.selectedIndex
* @return {null|Promise};
* @param {Function} context.commit
* @param {Function} context.dispatch
* @return {Promise|undefined}
*/
logQuickViewEvent: ( context, { action, selectedIndex } ) => {
if ( !action ) {
startNewEventLoggingSession: ( context ) => {
if ( context.state.sessionId ) {
// Failsafe in case a freak race condition lands us here despite
// having an active session
return;
}
return mw.loader.using( [ 'ext.eventLogging', 'ext.wikimediaEvents' ] ).then( function () {
// Upon invoking this, it must have become clear that there was no
// active session, and we must initiate a new one
const newSessionId = randomToken();
context.commit( 'SET_SESSION_ID', newSessionId );
context.commit( 'SET_SESSION_ID', mw.storage.get( 'wmE-sS--sessionId' ) );
/* eslint-disable camelcase */
const eventLogData = {
$schema: context.state.schema,
action,
result_display_position: selectedIndex,
wiki_id: context.state.wikiId,
platform: context.state.platform,
// session id requires ext.wikimediaEvents (which is the module that contains
// searchSatisfaction.js, where the session ID is generated) is loaded
session_id: context.state.sessionId
};
// Log the session initialization so that we're able to track how many
// search sessions there have been, even if there is no other interaction
// with SearchVue
const eventData = { action: 'new-session', selectedIndex: -1 };
const event = createEvent( context.state, eventData );
return loadEventLoggingAndSendEvent( event );
},
mw.eventLog.submit( 'mediawiki.searchpreview', eventLogData );
} );
/**
* @param {Object} context
* @param {Function} context.commit
* @param {Function} context.dispatch
* @param {VariableEventData} payload
* @return {Promise|undefined}
*/
logQuickViewEvent: ( context, payload ) => {
if ( !payload || !payload.action ) {
return;
}
// Make sure that a session has been started and a session id is known;
// this should always be the case since it's explicitly called right
// after startup, but we're still going to add it here once more to
// prevent against perfectly-timed race conditions
if ( !context.state.sessionId ) {
context.dispatch( 'startNewEventLoggingSession' );
// Since we're lacking the session ID (which will not be available
// until startNewEventLoggingSession ends up being executed,
// asynchronously), we can't log the event straight away.
// Instead, we'll just dispatch this exact same call again: once
// the dispatcher gets around to invoking it the next time, the
// session will be available, and we can log it then
context.dispatch( 'logQuickViewEvent', payload );
return;
}
const event = createEvent( context.state, payload );
return loadEventLoggingAndSendEvent( event );
}
}
};

查看文件

@ -1,35 +1,188 @@
const events = require( '../../../resources/store/modules/Event.js' );
const events = require( '../../../resources/store/modules/Event.js' ),
when = require( 'jest-when' ).when;
let dummyState;
const initialState = events.state();
when( global.mw.config.get )
.calledWith( 'wgDBname' )
.mockReturnValue( 'fakeDbName' );
when( global.mw.config.get )
.calledWith( 'skin' )
.mockReturnValue( 'minerva' );
beforeEach( () => {
dummyState = JSON.parse( JSON.stringify( initialState ) );
} );
describe( 'Events store', () => {
it( 'All initial state props equal to null', () => {
// eslint-disable-next-line es/no-object-values
Object.values( initialState ).forEach( ( prop ) => {
expect( prop ).toBeNull();
describe( 'Actions', () => {
let context;
beforeEach( () => {
context = {
commit: jest.fn(),
dispatch: jest.fn(),
state: JSON.parse( JSON.stringify( initialState ) )
};
} );
describe( 'initEventLoggingSession', () => {
describe( 'when eventLogging is not initialized', () => {
it( 'Creates a new session', () => {
events.actions.initEventLoggingSession( context );
expect( context.dispatch ).toHaveBeenCalled();
expect( context.dispatch ).toHaveBeenCalledWith( 'startNewEventLoggingSession' );
} );
} );
describe( 'when eventLogging is already initialized', () => {
beforeEach( () => {
context.state = Object.assign(
dummyState,
{
sessionId: 'dummySessionId'
}
);
} );
it( 'Should commit the same session ID', () => {
events.actions.initEventLoggingSession( context );
expect( context.commit ).toHaveBeenCalled();
expect( context.commit ).toHaveBeenCalledWith(
'SET_SESSION_ID',
expect.stringContaining( 'dummySessionId' )
);
} );
it( 'Should not create a new session', () => {
events.actions.initEventLoggingSession( context );
expect( context.dispatch ).not.toHaveBeenCalled();
} );
} );
} );
describe( 'startNewEventLoggingSession', () => {
describe( 'when eventLogging is not initialized', () => {
it( 'Creates a new session', () => {
events.actions.startNewEventLoggingSession( context );
expect( context.commit ).toHaveBeenCalled();
expect( context.commit ).toHaveBeenCalledWith(
'SET_SESSION_ID',
expect.stringContaining( 'fakeRandomSession' )
);
} );
it( 'Logs "new-session" event', ( done ) => {
events.actions.startNewEventLoggingSession( context )
.then( () => {
expect( mw.eventLog.submit ).toHaveBeenCalled();
expect( mw.eventLog.submit ).toHaveBeenCalledWith(
'mediawiki.searchpreview',
expect.objectContaining( {
action: 'new-session',
// eslint-disable-next-line camelcase
result_display_position: -1
} )
);
done();
} );
} );
} );
describe( 'when eventLogging is already initialized', () => {
beforeEach( () => {
context.state = Object.assign(
dummyState,
{
sessionId: 'dummySessionId'
}
);
} );
it( 'Should not create a new session', () => {
events.actions.startNewEventLoggingSession( context );
expect( context.commit ).not.toHaveBeenCalled();
} );
it( 'Should not logs "new-session" event', () => {
const actionResult = events.actions.startNewEventLoggingSession( context );
// If undefined it means that there is no promise and the action has returned
expect( actionResult ).toBeFalsy();
expect( mw.eventLog.submit ).not.toHaveBeenCalled();
} );
} );
} );
describe( 'logQuickViewEvent', () => {
let dummyPayload;
beforeEach( () => {
dummyPayload = {
action: 'dummy-action',
selectedIndex: 10
};
} );
describe( 'when the "action" parameter is missing', () => {
it( 'Does not perform any action', () => {
dummyPayload.action = null;
const result = events.actions.logQuickViewEvent( context, dummyPayload );
expect( result ).toBeFalsy();
} );
} );
describe( 'when sessionId is missing', () => {
it( 'Creates a new session', () => {
events.actions.logQuickViewEvent( context, dummyPayload );
expect( context.dispatch ).toHaveBeenCalled();
expect( context.dispatch ).toHaveBeenCalledWith( 'startNewEventLoggingSession' );
} );
it( 'Re-dispatches log action with same data', () => {
events.actions.logQuickViewEvent( context, dummyPayload );
expect( context.dispatch ).toHaveBeenCalled();
expect( context.dispatch ).toHaveBeenCalledWith( 'logQuickViewEvent', dummyPayload );
} );
} );
describe( 'when all parameters are set and session exist', () => {
beforeEach( () => {
context.state = Object.assign(
dummyState,
{
sessionId: 'dummySessionId'
}
);
} );
it( 'Should not create a new session', () => {
events.actions.logQuickViewEvent( context, dummyPayload );
expect( context.commit ).not.toHaveBeenCalled();
} );
it( 'Should log a "dummy-action" event', ( done ) => {
events.actions.logQuickViewEvent( context, dummyPayload )
.then( () => {
expect( mw.eventLog.submit ).toHaveBeenCalled();
expect( mw.eventLog.submit ).toHaveBeenCalledWith(
'mediawiki.searchpreview',
expect.objectContaining( {
action: 'dummy-action',
// eslint-disable-next-line camelcase
result_display_position: 10
} )
);
done();
} );
} );
} );
} );
} );
it( 'Mutate the initial event state', () => {
const initialData = {
sessionId: null,
schema: '/analytics/mediawiki/searchpreview/2.0.0',
wikiId: 'wiki',
platform: 'desktop'
};
events.mutations.SET_QUICK_VIEW_EVENT_PROPS( dummyState, initialData );
expect( dummyState ).toStrictEqual( initialData );
} );
it( 'Set session id', () => {
const dummySessionId = '1a0eadeb09fa71f58a0blzxtwolt';
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
expect( dummyState.sessionId ).toEqual( dummySessionId );
describe( 'Mutation', () => {
describe( 'SET_SESSION_ID', () => {
it( 'changes the sessionId in state', () => {
const dummySessionId = 'dummySessionId';
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
expect( dummyState.sessionId ).toEqual( dummySessionId );
} );
it( 'set the SessionId in session Storage', () => {
const dummySessionId = 'dummySessionId';
events.mutations.SET_SESSION_ID( dummyState, dummySessionId );
expect( global.mw.storage.set ).toHaveBeenCalled();
} );
} );
} );
} );