/*! * EventLogging client * @author Ori Livneh */ 'use strict'; // config contains: // - baseUrl: corresponds to the $wgEventLoggingBaseUri configuration in PHP. // If set to false (default), then mw.eventLog.logEvent will not log events. // - serviceUri: corresponds to $wgEventLoggingServiceUri configuration in PHP. // If set to false (default), then mw.eventLog.submit will not log events. // - schemasInfo: Object mapping schema names to revision IDs or $schema URIs // - streamConfigs: Object mapping stream name to stream config (sampling rate, etc.) let config = require( './data.json' ); const BackgroundQueue = require( './BackgroundQueue.js' ); const queue = ( new BackgroundQueue( config.queueLingerSeconds ) ); // Support both 1 or "1" (T54542) const debugMode = Number( mw.user.options.get( 'eventlogging-display-web' ) ) === 1 || Number( mw.user.options.get( 'eventlogging-display-console' ) ) === 1; /** * Construct the streamName for a legacy EventLogging Schema. * * Legacy EventLogging Schemas are single use and have only one associated stream. * * @ignore * @private * @param {string} schemaName * @return {string} */ function makeLegacyStreamName( schemaName ) { return 'eventlogging_' + schemaName; } /** * @classdesc Client-side EventLogging API, including pub/sub subscriber functionality. * * The main API is `mw.eventLog.logEvent`. This is set up as a listener for * `event`-namespace topics in `mw.track`. Sampling utility methods are available * in two flavors. Other methods represent internal functionality, exposed only * to ease debugging code and writing tests. * * @class mw.eventLog * @singleton * @hideconstructor * @borrows MetricsClient#submit as submit * @borrows MetricsClient#dispatch as dispatch */ const core = { /** * Maximum length in chars that a beacon URL can have. * * Relevant: * - Length that browsers support (http://stackoverflow.com/a/417184/319266) * - Length that proxies support (e.g. Varnish) * - varnishlog (shm_reclen) * - varnishkafka * * @private * @property {number} maxUrlSize */ maxUrlSize: 2000, /** * Get the configured revision id or $schema URI * to use with events of a particular (legacy metawiki) EventLogging schema. * * @private * @param {string} schemaName Canonical schema name. * @return {number|string} * The revision id configured for this schema by instrumentation, * or a string $schema URI for use with Event Platform. */ getRevisionOrSchemaUri: function ( schemaName ) { return config.schemasInfo[ schemaName ] || -1; }, /** * Prepare an event for dispatch. * * This encapsulates the event data in a wrapper object with * the default metadata for the current web page. * * NOTE: for forwards compatibility with Event Platform schemas, * we hijack the wgEventLoggingSchemas revision to encode the * $schema URI. If the value for a schema defined in * EventLoggingSchemas is a string, it is assumed * to be an Event Platform $schema URI, not a MW revision id. * In this case, the event will be prepared to be POSTed to EventGate. * * @private * @param {string} schemaName Canonical schema name. * @param {Object} eventData Event data. * @return {Object} Encapsulated event. */ prepare: function ( schemaName, eventData ) { // Wrap eventData in EventLogging's EventCapsule. const event = { event: eventData, schema: schemaName, webHost: mw.config.get( 'wgServerName' ), wiki: mw.config.get( 'wgDBname' ) }, revisionOrSchemaUri = core.getRevisionOrSchemaUri( schemaName ); // Forward compatibility with Event Platform schemas and EventGate. // If the wgEventLoggingSchemas entry for this schemaName is a string, // assume it is the Event Platform relative $schema URI and that // we want this event POSTed to EventGate. // Make the event data forward compatible. if ( typeof revisionOrSchemaUri === 'string' ) { event.$schema = revisionOrSchemaUri; // eslint-disable-next-line event.client_dt = new Date().toISOString(); // Note: some fields will have defaults set by eventgate-wikimedia. // See: // - https://gerrit.wikimedia.org/r/plugins/gitiles/eventgate-wikimedia/+/refs/heads/master/eventgate-wikimedia.js#358 // - https://wikitech.wikimedia.org/wiki/Event_Platform/Schemas/Guidelines#Automatically_populated_fields } else { // Deprecated: // Assume revisionOrSchemaUri is the MW revision id for this // EventLogging schema. event.revision = revisionOrSchemaUri; } return event; }, /** * Construct the EventLogging URI based on the base URI and the * encoded and stringified data. * * @private * @param {Object} data Payload to send. * @return {string|boolean} The URI to log the event. */ makeBeaconUrl: function ( data ) { const queryString = encodeURIComponent( JSON.stringify( data ) ); return config.baseUrl + '?' + queryString + ';'; }, /** * Check whether a beacon url is short enough. * * @private * @param {string} schemaName Canonical schema name. * @param {string} url Beacon url. * @return {string|undefined} The error message in case of error. */ checkUrlSize: function ( schemaName, url ) { let message; if ( url.length > core.maxUrlSize ) { message = 'Url exceeds maximum length'; core.logFailure( schemaName, 'urlSize' ); mw.track( 'eventlogging.error', mw.format( '[$1] $2', schemaName, message ) ); return message; } }, /** * Make a "fire and forget" HTTP request to a specified URL. * * In older browsers that lack the Beacon API (`navigator.sendBeacon`), * this falls back to a detached Image request. * * @param {string} url URL to request from the server. * @memberof mw.eventLog */ sendBeacon: function ( url ) { if ( navigator.sendBeacon ) { try { navigator.sendBeacon( url ); } catch ( e ) { // Ignore, T86680. } } else { // Support IE 11: Fallback for Beacon API document.createElement( 'img' ).src = url; } }, /** * Add a pending callback to be flushed at a later time by the background queue. * * @param {Function} callback to enqueue and run when the queue is processed * @return undefined * @memberof mw.eventLog * @method */ enqueue: queue.add, /** * Construct and transmit to a remote server a record of some event * having occurred. Events are represented as JavaScript objects that * conform to a JSON Schema. The schema describes the properties the * event object may (or must) contain and their type. This method * represents the public client-side API of EventLogging. * * @param {string} schemaName Canonical schema name. * @param {Object} eventData Event object. * @return {jQuery.Promise} jQuery Promise object for the logging call. * @memberof mw.eventLog */ logEvent: function ( schemaName, eventData ) { const event = core.prepare( schemaName, eventData ); const deferred = $.Deferred(); // Assume that if $schema was set by core.prepare(), this // event should be POSTed to EventGate. if ( event.$schema ) { core.submit( makeLegacyStreamName( schemaName ), event ); deferred.resolveWith( event, [ event ] ); } else { const url = core.makeBeaconUrl( event ); const sizeError = core.checkUrlSize( schemaName, url ); if ( !sizeError ) { if ( config.baseUrl || debugMode ) { core.enqueue( function () { core.sendBeacon( url ); } ); } if ( debugMode ) { mw.track( 'eventlogging.debug', event ); } // TODO: deprecate the signature of this method by returning a meaningless // promise and moving the sizeError checking into debug mode deferred.resolveWith( event, [ event ] ); } else { deferred.rejectWith( event, [ event, sizeError ] ); } } return deferred.promise(); }, /** * Increment the error count in statsd for this schema. * * Should be called instead of logEvent in case of an error. * * @param {string} schemaName * @param {string} errorCode * @memberof mw.eventLog */ logFailure: function ( schemaName, errorCode ) { // Record this failure as a simple counter. By default "counter.*" goes nowhere. // The WikimediaEvents extension sends it to statsd. mw.track( 'counter.eventlogging.client_errors.' + schemaName + '.' + errorCode ); }, /** * Randomise inclusion based on population size and random token. * * Use #pageviewInSample or #sessionInSample instead. * * Note that token is coerced into 32 bits before calculating its mod with * the population size, while this does not make possible to sample in a rate below * 1/2^32 and our token space is 2^80 this in practice is not a problem * as schemas that are sampled sparsely are so with ratios like 1/10,000 * so our "sampling space" is in practice quite smaller than the token * "random space" * * @private * @param {number} populationSize One in how many should return true. * @param {string} [explicitToken] at least 32 bit integer in HEX format * @return {boolean} */ randomTokenMatch: function ( populationSize, explicitToken ) { const token = explicitToken || mw.user.generateRandomSessionId(), rand = parseInt( token.slice( 0, 8 ), 16 ); return rand % populationSize === 0; }, /** * Determine whether the current sessionId is sampled given a sampling ratio. * This method is deterministic given same sampling rate and sessionId, * so sampling is sticky given a session and a sampling rate * * @param {number} populationSize One in how many should be included. * 0 means nobody, 1 is 100%, 2 is 50%, etc. * @return {boolean} * @memberof mw.eventLog */ sessionInSample: function ( populationSize ) { // Use the same unique random identifier within the same session // to allow correlation between multiple events. return this.randomTokenMatch( populationSize, mw.user.sessionId() ); }, /** * Determine whether the current event is sampled given a sampling ratio * per pageview * * @deprecated Use #pageviewInSample * @param {number} populationSize One in how many should be included. * 0 means nobody, 1 is 100%, 2 is 50%, etc. * @return {boolean} */ eventInSample: function ( populationSize ) { // Use the same unique random identifier within the same page load // to allow correlation between multiple events. return this.randomTokenMatch( populationSize, mw.user.getPageviewToken() ); }, /** * Determine whether the current event is sampled given a sampling ratio * per pageview. * * @param {number} populationSize One in how many should be included. * 0 means nobody, 1 is 100%, 2 is 50%, etc. * @return {boolean} * @memberof mw.eventLog */ pageviewInSample: function ( populationSize ) { // Use the same unique random identifier within the same page load // to allow correlation between multiple events. return this.randomTokenMatch( populationSize, mw.user.getPageviewToken() ); } }; // Deprecate the old core.inSample function and introduce a // replacement, core.pageviewInSample, to transition to the // new function for handling pageview sampling. // Apply mw.log.deprecate to the old inSample function. mw.log.deprecate( core, 'inSample', core.pageviewInSample, 'Use "mw.eventLog.pageviewInSample" instead.', 'mw.eventLog.inSample' ); // //////////////////////////////////////////////////////////////////// // MEP Upgrade Zone // // As we upgrade EventLogging to use MEP components, we will refactor // code from above to here. https://phabricator.wikimedia.org/T238544 // //////////////////////////////////////////////////////////////////// const MediaWikiMetricsClientIntegration = require( './MediaWikiMetricsClientIntegration.js' ); const EventSubmitter = require( './EventSubmitter.js' ); const MetricsClient = require( '../lib/metrics-platform/MetricsClient.js' ); function initMetricsClient() { const integration = new MediaWikiMetricsClientIntegration(); const eventSubmitter = new EventSubmitter( config.serviceUri, core.enqueue.bind( core ), debugMode ); const metricsClient = new MetricsClient( integration, config.streamConfigs, eventSubmitter ); // TODO (phuedx, 2024/09/09): DRY this up core.submit = metricsClient.submit.bind( metricsClient ); core.dispatch = metricsClient.dispatch.bind( metricsClient ); core.submitInteraction = metricsClient.submitInteraction.bind( metricsClient ); core.submitClick = metricsClient.submitClick.bind( metricsClient ); core.newInstrument = metricsClient.newInstrument.bind( metricsClient ); core.isCurrentUserEnrolled = metricsClient.isCurrentUserEnrolled.bind( metricsClient ); } initMetricsClient(); core.storage = { get: function ( name ) { return mw.cookie.get( 'el-' + name ); }, set: function ( name, value ) { mw.cookie.set( 'el-' + name, value ); }, unset: function ( name ) { mw.cookie.set( 'el-' + name, null ); } }; core.id = ( function () { const UINT32_MAX = 4294967295; // (2^32) - 1 let pageviewId = null, sessionId = null; // Provided by the sessionTick instrument in WikimediaEvents. mw.trackSubscribe( 'sessionReset', function () { core.id.resetSessionId(); } ); return { resetPageviewId: function () { pageviewId = null; }, resetSessionId: function () { sessionId = null; core.storage.unset( 'sessionId' ); }, generateId: function () { return mw.user.generateRandomSessionId(); }, normalizeId: function ( id ) { return parseInt( id.slice( 0, 8 ), 16 ) / UINT32_MAX; }, getPageviewId: function () { if ( !pageviewId ) { pageviewId = core.id.generateId(); } return pageviewId; }, getSessionId: function () { if ( !sessionId ) { // // If there is no runtime value for SESSION_ID, // try to load a value from persistent store. // sessionId = core.storage.get( 'sessionId' ); if ( !sessionId ) { // // If there is no value in the persistent store, // generate a new value for SESSION_ID, and write // the update to the persistent store. // sessionId = core.id.generateId(); core.storage.set( 'sessionId', sessionId ); } } return sessionId; } }; }() ); /** * Provide the user's edit count as a low-granularity bucket name. * * @param {number|null} editCount User edit count, or null for anonymous performers. * @return {string|null} `null` for anonymous performers. * * Do not use this value in conjunction with other edit count * bucketing, or you will deanonymize users to some degree. * * @memberof mw.eventLog */ function getUserEditCountBucket( editCount ) { if ( editCount === null ) { return null; } if ( editCount === 0 ) { return '0 edits'; } if ( editCount < 5 ) { return '1-4 edits'; } if ( editCount < 100 ) { return '5-99 edits'; } if ( editCount < 1000 ) { return '100-999 edits'; } return '1000+ edits'; } mw.config.set( 'wgUserEditCountBucket', getUserEditCountBucket( mw.config.get( 'wgUserEditCount' ) ) ); // Not allowed outside unit tests if ( window.QUnit ) { core.setOptionsForTest = function ( opts ) { const originalOptions = config; config = opts; // Reinitialise the Metrics Platform client. initMetricsClient(); return originalOptions; }; core.BackgroundQueue = BackgroundQueue; core.makeLegacyStreamName = makeLegacyStreamName; core.getUserEditCountBucket = getUserEditCountBucket; core.getQueue = function () { return queue; }; core.MediaWikiMetricsClientIntegration = MediaWikiMetricsClientIntegration; } module.exports = core;