/*! * VisualEditor MediaWiki event subscriber. * * Subscribes to ve.track() events and routes them to mw.track(). * * @copyright 2011-2020 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ ( function () { var actionPrefixMap = { firstChange: 'first_change', saveIntent: 'save_intent', saveAttempt: 'save_attempt', saveSuccess: 'save_success', saveFailure: 'save_failure' }, trackdebug = !!mw.Uri().query.trackdebug, firstInitDone = false; function getEditingSessionIdFromRequest() { return mw.config.get( 'wgWMESchemaEditAttemptStepSessionId' ) || mw.Uri().query.editingStatsId; } var timing = {}; var editingSessionId = getEditingSessionIdFromRequest() || mw.user.generateRandomSessionId(); ve.init.editingSessionId = editingSessionId; function log() { // mw.log is a no-op unless resource loader is in debug mode, so // this allows trackdebug to work independently (T211698) // eslint-disable-next-line no-console console.log.apply( console, arguments ); } function inSample() { // Not using mw.eventLog.inSample() because we need to be able to pass our own editingSessionId return mw.eventLog.randomTokenMatch( 1 / mw.config.get( 'wgWMESchemaEditAttemptStepSamplingRate' ), editingSessionId ); } function addABTestData( data ) { // DiscussionTools New Topic A/B test for logged out users if ( !mw.config.get( 'wgDiscussionToolsABTest' ) ) { return; } if ( mw.config.get( 'wgDiscussionToolsABTestBucket' ) ) { data.bucket = mw.config.get( 'wgDiscussionToolsABTestBucket' ); } } function computeDuration( action, event, timeStamp ) { if ( event.timing !== undefined ) { return event.timing; } switch ( action ) { case 'ready': return timeStamp - timing.init; case 'loaded': return timeStamp - timing.init; case 'firstChange': return timeStamp - timing.ready; case 'saveIntent': return timeStamp - timing.ready; case 'saveAttempt': return timeStamp - timing.saveIntent; case 'saveSuccess': case 'saveFailure': // HERE BE DRAGONS: the caller must compute these themselves // for sensible results. Deliberately sabotage any attempts to // use the default by returning -1 mw.log.warn( 've.init.mw.trackSubscriber: Do not rely on default timing value for saveSuccess/saveFailure' ); return -1; case 'abort': switch ( event.type ) { case 'preinit': return timeStamp - timing.init; case 'nochange': case 'switchwith': case 'switchwithout': case 'switchnochange': case 'abandon': return timeStamp - timing.ready; case 'abandonMidsave': return timeStamp - timing.saveAttempt; } } mw.log.warn( 've.init.mw.trackSubscriber: Unrecognized action', action ); return -1; } /** * Log the equivalent of an EditAttemptStep event via * [the Metrics Platform](https://wikitech.wikimedia.org/wiki/Metrics_Platform). * * See https://phabricator.wikimedia.org/T309013. * * @param {Object} data * @param {string} actionPrefix */ function logEditViaMetricsPlatform( data, actionPrefix ) { var eventName = 'eas.ve.' + actionPrefix; /* eslint-disable camelcase */ var customData = ve.extendObject( { editor_interface: 'visualeditor', integration: ve.init && ve.init.target && ve.init.target.constructor.static.integrationType || 'page', editing_session_id: editingSessionId }, data ); if ( !mw.config.get( 'wgRevisionId' ) ) { // eslint-disable-next-line no-jquery/no-global-selector customData.revision_id = +$( 'input[name=parentRevId]' ).val() || 0; } /* eslint-enable camelcase */ delete customData.action; // Sampling rate (and therefore whether a stream should oversample) is captured in the // stream config ($wgEventStreams). delete customData.is_oversample; // Platform can be derived from the agent_client_platform_family context attribute mixed in // by the JavaScript Metrics Platform Client. The context attribute will be // "desktop_browser" or "mobile_browser" depending on whether the MobileFrontend extension // has signalled that it is enabled. delete customData.platform; mw.eventLog.dispatch( eventName, customData ); } function mwEditHandler( topic, data, timeStamp ) { var action = topic.split( '.' )[ 1 ], actionPrefix = actionPrefixMap[ action ] || action, duration = 0; if ( action === 'init' ) { if ( firstInitDone ) { // Regenerate editingSessionId editingSessionId = mw.user.generateRandomSessionId(); ve.init.editingSessionId = editingSessionId; } firstInitDone = true; } if ( action === 'abort' && ( data.type === 'unknown' || data.type === 'unknown-edited' ) ) { if ( timing.saveAttempt && timing.saveSuccess === undefined && timing.saveFailure === undefined ) { data.type = 'abandonMidsave'; } else if ( timing.init && timing.ready === undefined ) { data.type = 'preinit'; } else if ( data.type === 'unknown' ) { data.type = 'nochange'; } else { data.type = 'abandon'; } } // Convert mode=source/visual to interface name if ( data && data.mode ) { // eslint-disable-next-line camelcase data.editor_interface = data.mode === 'source' ? 'wikitext-2017' : 'visualeditor'; delete data.mode; } if ( !data.platform ) { if ( ve.init && ve.init.target && ve.init.target.constructor.static.platformType ) { data.platform = ve.init.target.constructor.static.platformType; } else { data.platform = 'other'; // TODO: outright abort in this case, once we think we've caught everything mw.log.warn( 've.init.mw.trackSubscriber: no target available and no platform specified', action ); } } // Schema's kind of a mess of special properties if ( action === 'init' || action === 'abort' || action === 'saveFailure' ) { data[ actionPrefix + '_type' ] = data.type; } if ( action === 'init' || action === 'abort' ) { data[ actionPrefix + '_mechanism' ] = data.mechanism; } if ( action !== 'init' ) { // Schema actually does have an init_timing field, but we don't want to // store it because it's not meaningful. duration = Math.round( computeDuration( action, data, timeStamp ) ); data[ actionPrefix + '_timing' ] = duration; } if ( action === 'saveFailure' ) { data[ actionPrefix + '_message' ] = data.message; } // Remove renamed properties delete data.type; delete data.mechanism; delete data.timing; delete data.message; if ( action === 'abort' ) { timing = {}; } else { timing[ action ] = timeStamp; } /* eslint-enable camelcase */ addABTestData( data ); logEditViaMetricsPlatform( data, actionPrefix ); if ( !inSample() && !mw.config.get( 'wgWMESchemaEditAttemptStepOversample' ) && !trackdebug ) { return; } /* eslint-disable camelcase */ var event = ve.extendObject( { version: 1, action: action, is_oversample: !inSample(), editor_interface: 'visualeditor', integration: ve.init && ve.init.target && ve.init.target.constructor.static.integrationType || 'page', page_id: mw.config.get( 'wgArticleId' ), page_title: mw.config.get( 'wgPageName' ), page_ns: mw.config.get( 'wgNamespaceNumber' ), // eslint-disable-next-line no-jquery/no-global-selector revision_id: mw.config.get( 'wgRevisionId' ) || +$( 'input[name=parentRevId]' ).val() || 0, editing_session_id: editingSessionId, page_token: mw.user.getPageviewToken(), session_token: mw.user.sessionId(), user_id: mw.user.getId(), user_editcount: mw.config.get( 'wgUserEditCount', 0 ), mw_version: mw.config.get( 'wgVersion' ) }, data ); if ( mw.user.isAnon() ) { event.user_class = 'IP'; } if ( trackdebug ) { log( topic, duration + 'ms', event ); } else { mw.track( 'event.EditAttemptStep', event ); } } function mwTimingHandler( topic, data ) { // Add type for save errors; not in the topic for stupid historical reasons if ( topic === 'mwtiming.performance.user.saveError' ) { topic = topic + '.' + data.type; } // Map mwtiming.foo --> timing.ve.foo.mobile topic = topic.replace( /^mwtiming/, 'timing.ve.' + data.targetName ); if ( trackdebug ) { log( topic, Math.round( data.duration ) + 'ms' ); } else { mw.track( topic, data.duration ); } } function activityHandler( topic, data ) { var feature = topic.split( '.' )[ 1 ]; if ( !inSample() && !mw.config.get( 'wgWMESchemaEditAttemptStepOversample' ) && !trackdebug ) { return; } if ( ve.init.target && ( ve.init.target.constructor.static.platformType !== 'desktop' ) ) { // We want to log activity events when we're also logging to // EditAttemptStep. The EAS events are only fired from DesktopArticleTarget // in this repo. As such, we suppress this unless the current target is at // least inheriting that. (Other tools may fire their own instances of // those events, but probably need to reimplement this anyway for // session-identification reasons.) return; } /* eslint-disable camelcase */ var event = { feature: feature, action: data.action, editingSessionId: editingSessionId, is_oversample: !inSample(), user_id: mw.user.getId(), user_editcount: mw.config.get( 'wgUserEditCount', 0 ), editor_interface: ve.getProp( ve, 'init', 'target', 'surface', 'mode' ) === 'source' ? 'wikitext-2017' : 'visualeditor', integration: ve.getProp( ve, 'init', 'target', 'constructor', 'static', 'integrationType' ) || 'page', platform: ve.getProp( ve, 'init', 'target', 'constructor', 'static', 'platformType' ) || 'other' }; /* eslint-enable camelcase */ addABTestData( data ); if ( trackdebug ) { log( topic, event ); } else { mw.track( 'event.VisualEditorFeatureUse', event ); } } // Only log events if the WikimediaEvents extension is installed. // It provides variables that the above code depends on and registers the schemas. if ( mw.config.exists( 'wgWMESchemaEditAttemptStepSamplingRate' ) ) { // Ensure 'ext.eventLogging' first, it provides mw.eventLog.randomTokenMatch. mw.loader.using( 'ext.eventLogging' ).done( function () { ve.trackSubscribe( 'mwedit.', mwEditHandler ); ve.trackSubscribe( 'mwtiming.', mwTimingHandler ); ve.trackSubscribe( 'activity.', activityHandler ); } ); } }() );