| /*! | |
| * Listen for run-time errors in client-side JavaScript, | |
| * and log key information to EventGate via HTTP POST. | |
| * | |
| * Launch task: https://phabricator.wikimedia.org/T235189 | |
| */ | |
| /* eslint-disable max-len */ | |
| /** | |
| * @typedef ModuleConfig | |
| * @property {string} WMEClientErrorIntakeURL | |
| */ | |
| const moduleConfig = /** @type {ModuleConfig} */ require( /** @type {string} */ ( './config.json' ) ); | |
| // Only log up to this many errors per page (T259371) | |
| const errorLimit = 5; | |
| let errorCount = 0; | |
| // Browser stack trace strings are usually provided on the Error object, | |
| // and render each stack frame on its own line, e.g.: | |
| // | |
| // WebKit browsers: | |
| // "at foo (http://w.org/ex.js:11:22)" | |
| // | |
| // Gecko browsers: | |
| // "foo@http://w.org/ex.js:11:22" | |
| // | |
| // The format is not standardized, but the two given above predominate, | |
| // reflecting the two major browser engine lineages: | |
| // | |
| // WebKit Gecko | |
| // / \ | | |
| // Safari Chrome Firefox | |
| // / | \ | |
| // / | \ | |
| // Opera 12+ Edge Brave | |
| // | |
| // Given below are regular expressions that extract the "function name" and | |
| // "location" portions of such strings. | |
| // | |
| // For the examples above, a successful match would yield: | |
| // | |
| // [ "foo", "http://w.org/ex.js:11:22" ] | |
| // | |
| // This pair can then be re-composed into a new string with whatever format is desired. | |
| // | |
| // begin end | |
| // non-capture non-capture | |
| // group group | |
| // | | | |
| // /|\ /| | |
| const regexWebKit = /^\s*at (?:(.*?)\()?(.*?:\d+:\d+)\)?\s*$/i; | |
| // - -- --- -- ----------- --- - - | |
| // / / / | | | | \___ | |
| // line start / group 1, | | | | \ | |
| // / function | group 2, | any line | |
| // any # of name | url:line:col | # of end | |
| // spaces (maybe | | spaces | |
| // empty) | | | |
| // | | | |
| // literal literal | |
| // '(' ')' | |
| // (or nothing) | |
| // | |
| // begin end | |
| // outer outer | |
| // non-capture non-capture | |
| // group group | |
| // \__ begin end | | |
| // | inner inner | | |
| // | non-capture non-capture | | |
| // | group group | | |
| // | | | ___________| | |
| // /|\ /|\ /| /| | |
| const regexGecko = /^\s*(?:(.*?)(?:\(.*?\))?@)?(.*:\d+:\d+)\s*$/i; | |
| // - -- --- -- - -- - ---------- - - | |
| // / / / | | \_ \_ | |_ \__ line | |
| // line start / group 1, | | | | group 2, | end | |
| // / function | args | | url:line:col | | |
| // any # of name | | | | | |
| // spaces (maybe | | literal any | |
| // empty) | | '@' # of | |
| // | | spaces | |
| // literal literal | |
| // '(' ')' | |
| /** | |
| * @typedef ErrorDescriptor | |
| * @property {string} errorClass The class of the underlying error, e.g. `"Error"` | |
| * @property {string} errorMessage The error message | |
| * @property {string} fileUrl The URL of the file that the underlying error originated in | |
| * @property {string} [stackTrace] The normalized stack trace (see | |
| * `getNormalizedStackTraceLines()`) | |
| * @property {Error} [errorObject] The underlying error if available | |
| * @property {Object} [customErrorContext] Additional custom context to be logged with the error | |
| */ | |
| /** | |
| * Convert most native stack trace strings to a common format. | |
| * | |
| * If the input string does not match a supported format, | |
| * the output will be an empty array. | |
| * | |
| * @private | |
| * @param {string} str Native stack trace string from `Error.stack` | |
| * @return {string[]} Normalized lines of the stack trace | |
| */ | |
| function getNormalizedStackTraceLines( str ) { | |
| const result = []; | |
| const lines = str.split( '\n' ); | |
| let parts; | |
| let i; | |
| for ( i = 0; i < lines.length; i++ ) { | |
| // Try to boil each line of the stack trace string down to a function and | |
| // location pair, e.g. [ 'myFoo', 'myscript.js:1:23' ]. | |
| // using regexes that match the WebKit-like and Gecko-like stack trace | |
| // formats, in that order. | |
| // | |
| // A line will match only one of the two expressions (or neither). | |
| // Note that in JavaScript regex, the first value in the array is | |
| // the original string. | |
| parts = regexWebKit.exec( lines[ i ] ) || | |
| regexGecko.exec( lines[ i ] ); | |
| if ( parts ) { | |
| // If the line was successfully matched into two parts, then re-assemble | |
| // the parts in our output format. | |
| if ( parts[ 1 ] ) { | |
| result.push( 'at ' + parts[ 1 ] + ' ' + parts[ 2 ] ); | |
| } else { | |
| result.push( 'at ' + parts[ 2 ] ); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| /** | |
| * @param {string} message | |
| * @return {boolean} | |
| */ | |
| function shouldIgnoreMessage( message ) { | |
| return !!( message ) && [ | |
| // Users unintentionally sometimes, directly or indirectly, end up running multiple scripts | |
| // that try to load a gadget from another site by the same name. This can cause an error if | |
| // those uncoordinated attempts overlap. The error is harmless to the user as both copies are | |
| // probably the same and they don't mind getting whichever won the race (T262493). It is hard | |
| // for users to centralise and coordinate such naming and state across wikis without actual | |
| // server-side support for the "Global gadgets" concept (T22153), and users generally have no | |
| // incentive to avoid these errors since it works fine for them as it is. | |
| // Given this mistake is fairly common among power users that view many pages, we manually | |
| // exclude these errors from error logging (T266720). The error must not be excluded more generally since | |
| // it does represent a valid error condition for Wikimedia-supported modules. | |
| 'module already implemented: ext.gadget', | |
| // Ignore permission errors: | |
| // It is common for gadgets (or browser extensions) to create iframes and access nodes that are not | |
| // allowed by the current browser (T264245). There's little we can do about these errors, so | |
| // these should be excluded. | |
| 'Permission denied to access property', | |
| 'Permission denied to access object' | |
| ].some( ( m ) => message.includes( m ) ); | |
| } | |
| /** | |
| * Check whether error logging is supported for the current file URI | |
| * | |
| * @param {string} fileUrl | |
| * @return {boolean} | |
| */ | |
| function shouldIgnoreFileUrl( fileUrl ) { | |
| // | |
| // If the two URLs differ only by a fragment identifier (e.g. | |
| // 'example.org' vs. 'example.org#Section'), we consider them | |
| // to be matching. | |
| // Per spec, obj.url should never contain a fragment identifier, | |
| // yet we have observed this in the wild in several instances, | |
| // hence we must strip the identifier from both. | |
| // | |
| return fileUrl.split( '#' )[ 0 ] === location.href.split( '#' )[ 0 ] || | |
| // Various errors originate from scripts we do not control. These may be | |
| // prefixed by "blob:" or "javascript:" or one of the browser extensions. | |
| // These are not logged but may in future be diverted | |
| // to another channel (see T259383 for more information). | |
| // eslint-disable-next-line no-script-url | |
| fileUrl.startsWith( 'javascript:' ) || | |
| // Common pattern seen in the wild. Short for "inject JS". | |
| fileUrl.includes( '/inj_js/' ) || | |
| fileUrl.startsWith( 'blob:' ) || | |
| fileUrl.startsWith( 'jar:' ) || | |
| // from Windows file system. | |
| fileUrl.startsWith( 'C:\\' ) || | |
| fileUrl.startsWith( 'chrome://' ) || | |
| fileUrl.startsWith( 'chrome-extension://' ) || | |
| fileUrl.startsWith( 'safari-extension://' ) || | |
| fileUrl.startsWith( 'moz-extension://' ); | |
| } | |
| /** | |
| * See https://github.com/wikimedia/typescript-types/issues/48. | |
| * | |
| * @typedef UnprocessedErrorObject | |
| * @property {string|undefined} url? | |
| * @property {string} errorMessage? | |
| * @property {Error} errorObject? | |
| */ | |
| /** | |
| * Parses out an error descriptor from the error's stack trace. | |
| * | |
| * @param {Error|UnprocessedErrorObject|null} error The error that was caught. In theory an Error object, but it's | |
| * not strictly impossible for something else to end up here. | |
| * @return {ErrorDescriptor?} If the error can be parsed, then an `ErrorDescriptor` object; | |
| * otherwise, `null` | |
| */ | |
| function processErrorInstance( error ) { | |
| // Safety check: this method is bound to the 'error.*' mw.track prefix which is | |
| // fairly generic so conflicts might occur. Also, mw.errorLogger.logError() does | |
| // not attempt to verify that it was called with an Error, and the global error | |
| // handler will pass any value that was thrown, which is not restricted in | |
| // Javascript. Silently ignore unexpected data types. | |
| // Also ignore errors with no 'stack' property, which might not be present in some | |
| // uncommon browsers. Filtering out those events helps to reduce the noise of | |
| // exotic errors from fringe browsers. | |
| if ( !error || !( error instanceof Error ) || !error.stack ) { | |
| return null; | |
| } | |
| const stackTraceLines = getNormalizedStackTraceLines( String( error.stack ) ); | |
| if ( !stackTraceLines.length ) { | |
| return null; | |
| } | |
| const firstLine = stackTraceLines[ 0 ]; | |
| // getStackTraceLines returns lines in the form | |
| // | |
| // at [funcName] fileUrl:lineNo:colNo | |
| // | |
| // and we want to extract fileUrl. | |
| const parts = firstLine.split( ' ' ); | |
| const fileUrlParts = parts[ parts.length - 1 ].split( ':' ); | |
| // If the URL contains a port (or another unencoded ":" character?), then we need to | |
| // reconstruct it from the remaining parts. | |
| const fileUrl = fileUrlParts.slice( 0, -2 ).join( ':' ); | |
| return { | |
| // @ts-ignore https://github.com/microsoft/TypeScript/issues/3841 | |
| errorClass: error.constructor.name, | |
| errorMessage: error.message, | |
| fileUrl: fileUrl, | |
| stackTrace: stackTraceLines.join( '\n' ), | |
| errorObject: error, | |
| customErrorContext: /** @type {Error & {error_context?: Object}} */ ( error ).error_context | |
| }; | |
| } | |
| /** | |
| * A simple transformation for common normalization problems. | |
| * | |
| * @param {string} message | |
| * @return {string} normalized version of message | |
| */ | |
| function normalizeErrorMessage( message ) { | |
| // T262627 - drop "Uncaught" from the beginning of error messages (Chrome browser), | |
| // for consistency with Firefox (no "Uncaught") | |
| return message.replace( /^Uncaught /, '' ); | |
| } | |
| /** | |
| * @param {UnprocessedErrorObject|null|undefined} [errorLoggerObject] | |
| * @return {ErrorDescriptor|null} | |
| */ | |
| function processErrorLoggerObject( errorLoggerObject ) { | |
| if ( !errorLoggerObject ) { | |
| return null; | |
| } | |
| const errorObject = errorLoggerObject.errorObject; | |
| const stackTrace = errorObject && errorObject.stack ? | |
| getNormalizedStackTraceLines( errorObject.stack ).join( '\n' ) : | |
| ''; | |
| return { | |
| // @ts-ignore https://github.com/microsoft/TypeScript/issues/3841 | |
| errorClass: ( errorObject && errorObject.constructor.name ) || '', | |
| errorMessage: normalizeErrorMessage( errorLoggerObject.errorMessage ), | |
| // file url may not be defined given cached scripts run from localStorage. | |
| // If not explicitly set to undefined (T266517) to support filtering but still log. | |
| fileUrl: errorLoggerObject.url || 'undefined', | |
| stackTrace: stackTrace, | |
| errorObject: errorObject | |
| }; | |
| } | |
| /** | |
| * Gets whether or not the error, described by an `ErrorDescriptor` object, should be logged. | |
| * | |
| * @param {ErrorDescriptor} descriptor | |
| * @return {boolean} | |
| */ | |
| function shouldLog( descriptor ) { | |
| if ( descriptor.fileUrl === 'undefined' && descriptor.errorMessage === 'Script error.' ) { | |
| // ScriptErrors do not have stack traces and are inactionable without file uri. | |
| // See T266517#6906587 for more background. | |
| return false; | |
| } | |
| // If we are in an iframe do not log errors. (T264245) | |
| try { | |
| if ( window.self !== window.top ) { | |
| return false; | |
| } | |
| } catch ( e ) { | |
| // permission was denied, so assume iframe. | |
| return false; | |
| } | |
| if ( mw.storage.session.get( 'client-error-opt-out' ) ) { | |
| // Invalid error object or the user has opted out of error logging. | |
| return false; | |
| } | |
| if ( shouldIgnoreFileUrl( descriptor.fileUrl ) ) { | |
| // When the error lacks a URL, or the URL is defaulted to page | |
| // location, the stack trace is rarely meaningful, if ever. | |
| // | |
| // It may have been censored by the browser due to cross-site | |
| // origin security requirements, or the code may have been | |
| // executed as part of an eval, or some other weird thing may | |
| // be happening. | |
| // | |
| // We discard such errors because without a stack trace, they | |
| // are not really within our power to fix. (T259369, T261523) | |
| // | |
| // If the two URLs differ only by a fragment identifier (e.g. | |
| // 'example.org' vs. 'example.org#Section'), we consider them | |
| // to be matching. | |
| // | |
| // Per spec, obj.url should never contain a fragment identifier, | |
| // yet we have observed this in the wild in several instances, | |
| // hence we must strip the identifier from both. | |
| // | |
| // Various errors originate from scripts we do not control. These may be | |
| // prefixed by "blob:" or one of the browser extensions. | |
| // These are not logged but may in future be diverted | |
| // to another channel (see T259383 for more information). | |
| return false; | |
| } | |
| // Stop repeated errors from e.g. setInterval (T259371) | |
| if ( errorCount >= errorLimit ) { | |
| return false; | |
| } | |
| errorCount++; | |
| if ( shouldIgnoreMessage( descriptor.errorMessage ) ) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * @typedef ErrorContext | |
| * @property {string} [special_page] | |
| * @property {string} [gadgets] | |
| * @property {string} component | |
| * @property {string} wiki | |
| * @property {string} version | |
| * @property {string} skin | |
| * @property {string} action | |
| * @property {string} is_logged_in | |
| * @property {string} is_mobile_frontend_enabled whether MobileFrontend ran on the page. | |
| * @property {string} namespace | |
| * @property {string} debug | |
| * @property {string} banner_shown | |
| * @property {Object} [experiment_assignments] | |
| */ | |
| /** | |
| * Log the error to the "mediawiki.client.error" stream on the specified EventGate instance. | |
| * | |
| * @param {string} intakeURL The URL of the EventGate instance | |
| * @param {ErrorDescriptor} descriptor | |
| * @param {string} [component] The component which logged this error | |
| */ | |
| function log( intakeURL, descriptor, component ) { | |
| let gadgets = ''; | |
| const host = location.host; | |
| const protocol = location.protocol; | |
| const search = location.search; | |
| const hash = location.hash; | |
| const canonicalName = mw.config.get( 'wgCanonicalSpecialPageName' ); | |
| const url = canonicalName ? | |
| // T266504: Rewrites URL to canonical name to allow grouping. | |
| // note: if URL is in form `<host>/w/index.php?title=Spécial:Préférences` this will be converted to | |
| // "<host>/wiki/Special:Preferences?title=Sp%C3%A9cial:Pr%C3%A9f%C3%A9rences" | |
| protocol + '//' + host + mw.util.getUrl( 'Special:' + canonicalName ) + search + hash : | |
| location.href; | |
| /** | |
| * @typedef {Object} CentralNotice | |
| * @property {Function?} isBannerShown | |
| */ | |
| // @ts-ignore https://github.com/wikimedia/typescript-types/issues/46 | |
| const centralNotice = /** @type {CentralNotice} */ ( mw.centralNotice ); | |
| // Extra data that can be specified as-needed. Note that the values must always be strings. | |
| /** @type ErrorContext */ | |
| const errorContext = { | |
| component: component || 'unknown', | |
| wiki: mw.config.get( 'wgWikiID', '' ), | |
| version: mw.config.get( 'wgVersion', '' ), | |
| skin: mw.config.get( 'skin', '' ), | |
| action: mw.config.get( 'wgAction', '' ), | |
| // https://phabricator.wikimedia.org/T400852 | |
| is_mobile_frontend_enabled: String( | |
| !( mw.config.get( 'wgMFMode' ) === null ) | |
| ), | |
| is_logged_in: String( !mw.user.isAnon() ), | |
| namespace: mw.config.get( 'wgCanonicalNamespace', '' ), | |
| debug: String( !!mw.config.get( 'debug', 0 ) ), | |
| // T265096 - record when a banner was shown. Might be a hint to catch errors originating | |
| // in banner code, which is otherwise difficult to diagnose. | |
| banner_shown: String( ( | |
| centralNotice && | |
| // T319498: mw.centralNotice.isBannerShown might or might not exist | |
| centralNotice.isBannerShown && | |
| centralNotice.isBannerShown() | |
| ) || false ) | |
| }; | |
| if ( canonicalName ) { | |
| errorContext.special_page = canonicalName; | |
| } | |
| // @ts-ignore https://github.com/wikimedia/typescript-types/issues/47 | |
| gadgets = mw.loader.getModuleNames().filter( ( module ) => module.match( /^ext\.gadget\./ ) && mw.loader.getState( module ) !== 'registered' ).map( ( /** @type string */ module ) => module.replace( /^ext\.gadget\./, '' ) ).join( ',' ); | |
| if ( gadgets ) { | |
| errorContext.gadgets = gadgets; | |
| } | |
| /** | |
| * @typedef {Object} TestKitchen | |
| * @property {Function?} getAssignments | |
| */ | |
| // @ts-ignore | |
| const testKitchen = /** @type {TestKitchen} */ ( mw.testKitchen ); | |
| const experimentAssignments = ( testKitchen && testKitchen.getAssignments && testKitchen.getAssignments() ) || {}; | |
| const experimentNames = Object.keys( experimentAssignments ); | |
| // If the current user is enrolled in one or more experiments, then add the enrollment | |
| // information to the error context. | |
| // | |
| // The enrollment information is encoded in the same way as the X-Experiment-Enrollments header | |
| // sent by Varnish, i.e. | |
| // | |
| // experiment1Name=groupName;experiment2Name=groupName;experiment3Name=groupName... | |
| if ( experimentNames.length ) { | |
| errorContext.experiment_assignments = experimentNames.map( | |
| ( experimentName ) => `${ experimentName }=${ experimentAssignments[ experimentName ] }` | |
| ) | |
| .join( ';' ); | |
| } | |
| const customErrorContext = descriptor.customErrorContext ? descriptor.customErrorContext : {}; | |
| navigator.sendBeacon( intakeURL, JSON.stringify( { | |
| meta: { | |
| // Name of the stream | |
| stream: 'mediawiki.client.error', | |
| // Domain of the web page | |
| domain: location.hostname | |
| }, | |
| // Schema used to validate events | |
| $schema: '/mediawiki/client/error/2.0.0', | |
| // Name of the error constructor | |
| error_class: descriptor.errorClass, | |
| // Message included with the Error object | |
| message: descriptor.errorMessage, | |
| // URL of the file causing the error | |
| file_url: descriptor.fileUrl, | |
| // URL of the web page. | |
| url: url, | |
| // Normalized stack trace string | |
| // We log undefined rather than empty string (consistent with file_url) to allow for filtering. | |
| stack_trace: descriptor.stackTrace || 'undefined', | |
| error_context: Object.assign( {}, errorContext, customErrorContext ) | |
| } ) ); | |
| } | |
| /** | |
| * Install a subscriber for Javascript errors that sends them to some | |
| * logging server. | |
| * | |
| * @param {string} intakeURL Where to POST the error event | |
| */ | |
| function install( intakeURL ) { | |
| // Capture errors which were logged manually via | |
| // mw.errorLogger.logError( <error>, <topic> ) | |
| mw.trackSubscribe( 'error.', ( topic, error ) => { | |
| if ( topic === 'error.uncaught' ) { | |
| // Will be logged via global.error. | |
| return; | |
| } | |
| const component = topic.replace( /^error\./, '' ); | |
| const descriptor = processErrorInstance( /** @type UnprocessedErrorObject */ ( error ) ); | |
| if ( descriptor && shouldLog( descriptor ) ) { | |
| log( intakeURL, descriptor, component ); | |
| } | |
| } ); | |
| // We capture unhandled Javascript errors by subscribing to the | |
| // 'global.error' topic. | |
| // | |
| // For more information, see mediawiki.errorLogger.js in MediaWiki, | |
| // which is responsible for directly handling the browser's | |
| // window.onerror events and producing equivalent messages to | |
| // the 'global.error' topic. | |
| mw.trackSubscribe( 'global.error', ( _, obj ) => { | |
| const descriptor = processErrorLoggerObject( /** @type UnprocessedErrorObject */ ( obj ) ); | |
| if ( descriptor && shouldLog( descriptor ) ) { | |
| log( intakeURL, descriptor ); | |
| } | |
| } ); | |
| } | |
| // Functionally this file is self-contained, but export some methods for testing. | |
| module.exports = { | |
| getNormalizedStackTraceLines, | |
| processErrorInstance, | |
| processErrorLoggerObject, | |
| log | |
| }; | |
| if ( | |
| // @ts-ignore | |
| !window.QUnit && | |
| navigator.sendBeacon !== undefined && | |
| moduleConfig.WMEClientErrorIntakeURL | |
| ) { | |
| // Only install the logger if: | |
| // | |
| // - We're not in a testing environment; | |
| // - The module has been properly configured; and | |
| // - The client supports the necessary browser features. | |
| install( moduleConfig.WMEClientErrorIntakeURL ); | |
| } |
US