diff --git a/.prettierignore b/.prettierignore index 94b59dabd6..a65a70802d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,8 @@ docs/**/* !injected/docs/**/* injected/src/types special-pages/pages/**/types +special-pages/shared/**/types +metrics/types injected/integration-test/extension/contentScope.js injected/src/features/Scriptlets **/*.json diff --git a/injected/integration-test/duckplayer-mobile.spec.js b/injected/integration-test/duckplayer-mobile.spec.js index 375db575c1..0de39ac1a8 100644 --- a/injected/integration-test/duckplayer-mobile.spec.js +++ b/injected/integration-test/duckplayer-mobile.spec.js @@ -147,3 +147,13 @@ test.describe('Overlay screenshot @screenshots', () => { await expect(page.locator('.html5-video-player')).toHaveScreenshot('overlay.png', { maxDiffPixels: 20 }); }); }); + +test.describe('Reporting exceptions', () => { + test('initial setup error', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.withRemoteConfig({ locale: 'en' }); + await overlays.messagingError(); + await overlays.gotoPlayerPage(); + await overlays.didSendMessagingException(); + }); +}); diff --git a/injected/integration-test/duckplayer-native.spec.js b/injected/integration-test/duckplayer-native.spec.js index 35053543d2..8e5beaf06d 100644 --- a/injected/integration-test/duckplayer-native.spec.js +++ b/injected/integration-test/duckplayer-native.spec.js @@ -170,3 +170,13 @@ test.describe('Duck Player Native custom error view', () => { await duckPlayer.didShowUnknownError(); }); }); + +test.describe('Reporting exceptions', () => { + test('initial setup error', async ({ page }, workerInfo) => { + const duckPlayer = DuckPlayerNative.create(page, workerInfo); + await duckPlayer.withRemoteConfig(); + await duckPlayer.messagingError(); + await duckPlayer.gotoBlankPage(); + await duckPlayer.didSendMessagingException(); + }); +}); diff --git a/injected/integration-test/duckplayer.spec.js b/injected/integration-test/duckplayer.spec.js index ca0aa628dd..0f4de706fa 100644 --- a/injected/integration-test/duckplayer.spec.js +++ b/injected/integration-test/duckplayer.spec.js @@ -369,3 +369,13 @@ test.describe('serp proxy', () => { await overlays.userValuesCallIsProxied(); }); }); + +test.describe('Reporting exceptions', () => { + test('initial setup error', async ({ page }, workerInfo) => { + const overlays = DuckplayerOverlays.create(page, workerInfo); + await overlays.withRemoteConfig({ locale: 'en' }); + await overlays.messagingError(); + await overlays.gotoPlayerPage(); + await overlays.didSendMessagingException(); + }); +}); diff --git a/injected/integration-test/page-objects/duckplayer-native.js b/injected/integration-test/page-objects/duckplayer-native.js index 34127dce81..b1d7c75a43 100644 --- a/injected/integration-test/page-objects/duckplayer-native.js +++ b/injected/integration-test/page-objects/duckplayer-native.js @@ -11,12 +11,16 @@ import { ResultsCollector } from './results-collector.js'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const configFiles = /** @type {const} */ (['native.json']); -const defaultInitialSetup = { +const featureName = 'duckPlayerNative'; + +/** @type {PageType} */ +const DEFAULT_PAGE_TYPE = 'YOUTUBE'; + +/** @type {import('../../src/features/duck-player-native.js').InitialSettings} */ +const DEFAULT_INITIAL_SETUP = { locale: 'en', }; -const featureName = 'duckPlayerNative'; - export class DuckPlayerNative { /** @type {Partial>} */ pages = { @@ -36,7 +40,7 @@ export class DuckPlayerNative { this.isMobile = platform.name === 'android' || platform.name === 'ios'; this.collector = new ResultsCollector(page, build, platform); this.collector.withMockResponse({ - initialSetup: defaultInitialSetup, + initialSetup: DEFAULT_INITIAL_SETUP, onCurrentTimestamp: {}, }); this.collector.withUserPreferences({ @@ -86,13 +90,22 @@ export class DuckPlayerNative { } /** - * @param {PageType} pageType + * Goes to the default page (YOUTUBE) without overriding initialSetup + * Useful for testing messaging errors + **/ + async gotoBlankPage() { + await this.gotoPage(); + } + + /** + * @param {PageType} [pageType] * @param {object} [params] * @param {PlayerPageVariants} [params.variant] * @param {string} [params.videoID] */ async gotoPage(pageType, params = {}) { - await this.withPageType(pageType); + if (pageType) await this.withPageType(pageType); + const page = this.pages[pageType || DEFAULT_PAGE_TYPE]; const defaultVariant = this.isMobile ? 'mobile' : 'default'; const { variant = defaultVariant, videoID = '123' } = params; @@ -101,8 +114,6 @@ export class DuckPlayerNative { ['variant', variant], ]); - const page = this.pages[pageType]; - await this.page.goto(page + '?' + urlParams.toString()); } @@ -122,7 +133,7 @@ export class DuckPlayerNative { * @return {Promise} */ async withPageType(pageType) { - const initialSetup = this.collector.mockResponses?.initialSetup || defaultInitialSetup; + const initialSetup = this.collector.mockResponses?.initialSetup || DEFAULT_INITIAL_SETUP; await this.collector.updateMockResponse({ initialSetup: { pageType, ...initialSetup }, @@ -134,7 +145,7 @@ export class DuckPlayerNative { * @return {Promise} */ async withPlaybackPaused(playbackPaused = true) { - const initialSetup = this.collector.mockResponses.initialSetup || defaultInitialSetup; + const initialSetup = this.collector.mockResponses.initialSetup || DEFAULT_INITIAL_SETUP; await this.collector.updateMockResponse({ initialSetup: { playbackPaused, ...initialSetup }, @@ -206,6 +217,34 @@ export class DuckPlayerNative { await this.simulateSubscriptionMessage('onUrlChanged', { pageType }); } + /** + * Simulates a messaging error by passing an empty initialSetup object + */ + async messagingError() { + await this.build.switch({ + android: async () => { + await this.collector.updateMockResponse({ + initialSetup: {}, + }); + }, + apple: async () => { + await this.collector.updateMockResponse({ + initialSetup: null, + }); + }, + 'apple-isolated': async () => { + await this.collector.updateMockResponse({ + initialSetup: null, + }); + }, + windows: async () => { + await this.collector.updateMockResponse({ + initialSetup: '', + }); + }, + }); + } + /* Messaging assertions */ async didSendInitialHandshake() { @@ -236,6 +275,42 @@ export class DuckPlayerNative { ]); } + /** + * @param {string} kind + * @param {string} message + */ + async didSendException(kind, message, context = 'contentScopeScripts') { + const messages = await this.collector.waitForMessage('reportMetric'); + expect(messages).toMatchObject([ + { + payload: { + context, + featureName: 'duckPlayerNative', + method: 'reportMetric', + params: { metricName: 'exception', params: { kind, message } }, + }, + }, + ]); + } + + async didSendMessagingException() { + await this.build.switch({ + android: async () => { + // Android produces a TypeError due to how its messaging lib is wired up + await this.didSendException('TypeError', "Cannot read properties of undefined (reading 'privatePlayerMode')"); + }, + apple: async () => { + await this.didSendException('MessagingError', 'an unknown error occurred'); + }, + 'apple-isolated': async () => { + await this.didSendException('MessagingError', 'an unknown error occurred', 'contentScopeScriptsIsolated'); + }, + windows: async () => { + await this.didSendException('MessagingError', 'unknown error'); + }, + }); + } + /* Thumbnail Overlay assertions */ async didShowOverlay() { diff --git a/injected/integration-test/page-objects/duckplayer-overlays.js b/injected/integration-test/page-objects/duckplayer-overlays.js index 1b0850a3ee..73b5122c1f 100644 --- a/injected/integration-test/page-objects/duckplayer-overlays.js +++ b/injected/integration-test/page-objects/duckplayer-overlays.js @@ -305,6 +305,34 @@ export class DuckplayerOverlays { }); } + /** + * Simulates a messaging error by passing an empty initialSetup object + */ + async messagingError() { + await this.build.switch({ + android: async () => { + await this.collector.updateMockResponse({ + initialSetup: {}, + }); + }, + apple: async () => { + await this.collector.updateMockResponse({ + initialSetup: null, + }); + }, + 'apple-isolated': async () => { + await this.collector.updateMockResponse({ + initialSetup: null, + }); + }, + windows: async () => { + await this.collector.updateMockResponse({ + initialSetup: '', + }); + }, + }); + } + /** * @param {keyof userValues} setting */ @@ -477,6 +505,42 @@ export class DuckplayerOverlays { ]); } + /** + * @param {string} kind + * @param {string} message + */ + async didSendException(kind, message, context = 'contentScopeScripts') { + const messages = await this.collector.waitForMessage('reportMetric'); + expect(messages).toMatchObject([ + { + payload: { + context, + featureName: 'duckPlayer', + method: 'reportMetric', + params: { metricName: 'exception', params: { kind, message } }, + }, + }, + ]); + } + + async didSendMessagingException() { + await this.build.switch({ + android: async () => { + // Android produces a TypeError due to how its messaging lib is wired up + await this.didSendException('TypeError', "Cannot read properties of undefined (reading 'privatePlayerMode')"); + }, + apple: async () => { + await this.didSendException('MessagingError', 'an unknown error occurred'); + }, + 'apple-isolated': async () => { + await this.didSendException('MessagingError', 'an unknown error occurred', 'contentScopeScriptsIsolated'); + }, + windows: async () => { + await this.didSendException('MessagingError', 'unknown error'); + }, + }); + } + /** * @return {Promise} */ diff --git a/injected/src/features/duck-player-native.js b/injected/src/features/duck-player-native.js index 70e7f60ccf..a293923408 100644 --- a/injected/src/features/duck-player-native.js +++ b/injected/src/features/duck-player-native.js @@ -4,6 +4,7 @@ import { DuckPlayerNativeMessages } from './duckplayer-native/messages.js'; import { setupDuckPlayerForNoCookie, setupDuckPlayerForSerp, setupDuckPlayerForYouTube } from './duckplayer-native/sub-feature.js'; import { Environment } from './duckplayer/environment.js'; import { Logger } from './duckplayer/util.js'; +import { MetricsReporter, EXCEPTION_KIND_INITIAL_SETUP_ERROR } from '../../../metrics/metrics-reporter.js'; /** * @import {DuckPlayerNativeSubFeature} from './duckplayer-native/sub-feature.js' @@ -14,8 +15,8 @@ import { Logger } from './duckplayer/util.js'; /** * @typedef InitialSettings - The initial payload used to communicate render-blocking information * @property {string} locale - UI locale - * @property {UrlChangeSettings['pageType']} pageType - The type of the current page - * @property {boolean} playbackPaused - Should video start playing or paused + * @property {UrlChangeSettings['pageType']} [pageType] - The type of the current page + * @property {boolean} [playbackPaused] - Should video start playing or paused */ /** @@ -29,18 +30,12 @@ export class DuckPlayerNativeFeature extends ContentFeature { /** @type {TranslationFn} */ t; - async init(args) { + init(args) { /** * This feature never operates in a frame */ if (isBeingFramed()) return; - const selectors = this.getFeatureSetting('selectors'); - if (!selectors) { - console.warn('No selectors found. Check remote config. Feature will not be initialized.'); - return; - } - const locale = args?.locale || args?.language || 'en'; const env = new Environment({ debug: this.isDebug, @@ -48,11 +43,33 @@ export class DuckPlayerNativeFeature extends ContentFeature { platform: this.platform, locale, }); + const metrics = new MetricsReporter(this.messaging); + + try { + const selectors = this.getFeatureSetting('selectors'); + if (!selectors) { + console.warn('No selectors found. Check remote config. Feature will not be initialized.'); + return; + } - // Translation function to be used by view components - this.t = (key) => env.strings('native.json')[key]; + // Translation function to be used by view components + this.t = (key) => env.strings('native.json')[key]; + + const messages = new DuckPlayerNativeMessages(this.messaging, env); + this.initDuckPlayerNative(messages, selectors, env) + // Using then instead of await because this is the public interface of the parent, which doesn't explicitly wait for promises to be resolved. + // eslint-disable-next-line promise/prefer-await-to-then + .catch((e) => { + console.error(e); + metrics.reportExceptionWithError(e); + }); + } catch (e) { + console.error(e); + metrics.reportExceptionWithError(e); + } + } - const messages = new DuckPlayerNativeMessages(this.messaging, env); + async initDuckPlayerNative(messages, selectors, env) { messages.subscribeToURLChange(({ pageType }) => { const playbackPaused = false; // This can be added to the event data in the future if needed this.urlDidChange(pageType, selectors, playbackPaused, env, messages); @@ -64,7 +81,14 @@ export class DuckPlayerNativeFeature extends ContentFeature { try { initialSetup = await messages.initialSetup(); } catch (e) { - console.warn('Failed to get initial setup', e); + console.warn(e); + return; + } + + if (!initialSetup) { + const message = 'InitialSetup data is missing'; + console.warn(message); + messages.metrics.reportException({ message, kind: EXCEPTION_KIND_INITIAL_SETUP_ERROR }); return; } @@ -118,7 +142,7 @@ export class DuckPlayerNativeFeature extends ContentFeature { if (document.readyState === 'loading') { const loadHandler = () => { logger.log('Running deferred load handlers'); - nextPage.onLoad(); + nextPage?.onLoad(); messages.notifyScriptIsReady(); }; document.addEventListener('DOMContentLoaded', loadHandler, { once: true }); diff --git a/injected/src/features/duck-player.js b/injected/src/features/duck-player.js index db6d1cb8fa..cdebf5be44 100644 --- a/injected/src/features/duck-player.js +++ b/injected/src/features/duck-player.js @@ -37,6 +37,7 @@ import { DuckPlayerOverlayMessages, OpenInDuckPlayerMsg, Pixel } from './duckpla import { isBeingFramed } from '../utils.js'; import { initOverlays } from './duckplayer/overlays.js'; import { Environment } from './duckplayer/environment.js'; +import { MetricsReporter } from '../../../metrics/metrics-reporter.js'; /** * @typedef UserValues - A way to communicate user settings @@ -92,20 +93,32 @@ export default class DuckPlayerFeature extends ContentFeature { if (!this.messaging) { throw new Error('cannot operate duck player without a messaging backend'); } + const metrics = new MetricsReporter(this.messaging); - const locale = args?.locale || args?.language || 'en'; - const env = new Environment({ - debug: this.isDebug, - injectName: import.meta.injectName, - platform: this.platform, - locale, - }); - const comms = new DuckPlayerOverlayMessages(this.messaging, env); + try { + const locale = args?.locale || args?.language || 'en'; + const env = new Environment({ + debug: this.isDebug, + injectName: import.meta.injectName, + platform: this.platform, + locale, + }); + const comms = new DuckPlayerOverlayMessages(this.messaging, env); - if (overlaysEnabled) { - initOverlays(overlaySettings.youtube, env, comms); - } else if (serpProxyEnabled) { - comms.serpProxy(); + if (overlaysEnabled) { + initOverlays(overlaySettings.youtube, env, comms) + // Using then instead of await because this is the public interface of the parent, which doesn't explicitly wait for promises to be resolved. + // eslint-disable-next-line promise/prefer-await-to-then + .catch((e) => { + console.error(e); + metrics.reportExceptionWithError(e); + }); + } else if (serpProxyEnabled) { + comms.serpProxy(); + } + } catch (e) { + console.error(e); + metrics.reportExceptionWithError(e); } } } diff --git a/injected/src/features/duckplayer-native/messages.js b/injected/src/features/duckplayer-native/messages.js index 2cfecf7f8c..4a4d5365e0 100644 --- a/injected/src/features/duckplayer-native/messages.js +++ b/injected/src/features/duckplayer-native/messages.js @@ -1,4 +1,5 @@ import * as constants from './constants.js'; +import { MetricsReporter, EXCEPTION_KIND_MESSAGING_ERROR } from '../../../..//metrics/metrics-reporter.js'; /** @import {YouTubeError} from './error-detection.js' */ /** @import {Environment} from '../duckplayer/environment.js' */ @@ -42,13 +43,19 @@ export class DuckPlayerNativeMessages { */ this.messaging = messaging; this.environment = environment; + this.metrics = new MetricsReporter(messaging); } /** - * @returns {Promise} + * @returns {Promise} */ - initialSetup() { - return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + async initialSetup() { + try { + return await this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** diff --git a/injected/src/features/duckplayer/components/ddg-video-overlay.js b/injected/src/features/duckplayer/components/ddg-video-overlay.js index 8b797d5dbc..f2fe32a4ed 100644 --- a/injected/src/features/duckplayer/components/ddg-video-overlay.js +++ b/injected/src/features/duckplayer/components/ddg-video-overlay.js @@ -5,6 +5,8 @@ import { appendImageAsBackground } from '../util.js'; import { VideoOverlay } from '../video-overlay.js'; import { createPolicy, html, trustedUnsafe } from '../../../dom-utils.js'; +const EXCEPTION_KIND_VIDEO_OVERLAY_ERROR = 'VideoOverlayError'; + /** * The custom element that we use to present our UI elements * over the YouTube player @@ -22,7 +24,7 @@ export class DDGVideoOverlay extends HTMLElement { */ constructor({ environment, params, ui, manager }) { super(); - if (!(manager instanceof VideoOverlay)) throw new Error('invalid arguments'); + if (!(manager instanceof VideoOverlay)) throw new Error('Invalid VideoOverlay manager'); this.environment = environment; this.ui = ui; this.params = params; @@ -121,7 +123,11 @@ export class DDGVideoOverlay extends HTMLElement { const optOutHandler = (e) => { if (e.isTrusted) { const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); + if (!(remember instanceof HTMLInputElement)) { + const error = new Error('Cannot find remember checkbox'); + error.name = EXCEPTION_KIND_VIDEO_OVERLAY_ERROR; + throw error; + } this.manager.userOptOut(remember.checked, params); } }; @@ -129,7 +135,11 @@ export class DDGVideoOverlay extends HTMLElement { if (e.isTrusted) { e.preventDefault(); const remember = containerElement.querySelector('input[name="ddg-remember"]'); - if (!(remember instanceof HTMLInputElement)) throw new Error('cannot find our input'); + if (!(remember instanceof HTMLInputElement)) { + const error = new Error('Cannot find remember checkbox'); + error.name = EXCEPTION_KIND_VIDEO_OVERLAY_ERROR; + throw error; + } this.manager.userOptIn(remember.checked, params); } }; diff --git a/injected/src/features/duckplayer/overlay-messages.js b/injected/src/features/duckplayer/overlay-messages.js index 3e61e5f471..a09c8ddd65 100644 --- a/injected/src/features/duckplayer/overlay-messages.js +++ b/injected/src/features/duckplayer/overlay-messages.js @@ -1,5 +1,6 @@ /* eslint-disable promise/prefer-await-to-then */ import * as constants from './constants.js'; +import { MetricsReporter, EXCEPTION_KIND_MESSAGING_ERROR } from '../../../../metrics/metrics-reporter.js'; /** * @typedef {import("@duckduckgo/messaging").Messaging} Messaging @@ -21,12 +22,13 @@ export class DuckPlayerOverlayMessages { */ this.messaging = messaging; this.environment = environment; + this.metrics = new MetricsReporter(messaging); } /** - * @returns {Promise} + * @returns {Promise} */ - initialSetup() { + async initialSetup() { if (this.environment.isIntegrationMode()) { return Promise.resolve({ userValues: { @@ -36,23 +38,38 @@ export class DuckPlayerOverlayMessages { ui: {}, }); } - return this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + try { + return await this.messaging.request(constants.MSG_NAME_INITIAL_SETUP); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** * Inform the native layer that an interaction occurred * @param {import("../duck-player.js").UserValues} userValues - * @returns {Promise} + * @returns {Promise} */ - setUserValues(userValues) { - return this.messaging.request(constants.MSG_NAME_SET_VALUES, userValues); + async setUserValues(userValues) { + try { + return await this.messaging.request(constants.MSG_NAME_SET_VALUES, userValues); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** - * @returns {Promise} + * @returns {Promise} */ - getUserValues() { - return this.messaging.request(constants.MSG_NAME_READ_VALUES, {}); + async getUserValues() { + try { + return await this.messaging.request(constants.MSG_NAME_READ_VALUES, {}); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** diff --git a/injected/src/features/duckplayer/overlays.js b/injected/src/features/duckplayer/overlays.js index 9de25b5442..3e7dd39de2 100644 --- a/injected/src/features/duckplayer/overlays.js +++ b/injected/src/features/duckplayer/overlays.js @@ -2,6 +2,7 @@ import { DomState } from './util.js'; import { ClickInterception, Thumbnails } from './thumbnails.js'; import { VideoOverlay } from './video-overlay.js'; import { registerCustomElements } from './components/index.js'; +import { EXCEPTION_KIND_INITIAL_SETUP_ERROR } from '../../../../metrics/metrics-reporter.js'; /** * @typedef {object} OverlayOptions @@ -21,17 +22,18 @@ export async function initOverlays(settings, environment, messages) { // bind early to attach all listeners const domState = new DomState(); - /** @type {import("../duck-player.js").OverlaysInitialSettings} */ + /** @type {import("../duck-player.js").OverlaysInitialSettings|null} */ let initialSetup; try { initialSetup = await messages.initialSetup(); } catch (e) { - console.warn(e); + // console.warn(e); return; } if (!initialSetup) { - console.warn('cannot continue without user settings'); + const message = 'InitialSetup data is missing'; + messages.metrics.reportException({ message, kind: EXCEPTION_KIND_INITIAL_SETUP_ERROR }); return; } diff --git a/injected/src/features/duckplayer/video-overlay.js b/injected/src/features/duckplayer/video-overlay.js index 129de68800..07a50951be 100644 --- a/injected/src/features/duckplayer/video-overlay.js +++ b/injected/src/features/duckplayer/video-overlay.js @@ -254,10 +254,16 @@ export class VideoOverlay { elem.text = mobileStrings(this.environment.strings('overlays.json')); elem.addEventListener(DDGVideoOverlayMobile.OPEN_INFO, () => this.messages.openInfo()); elem.addEventListener(DDGVideoOverlayMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { - return this.mobileOptOut(e.detail.remember).catch(console.error); + return this.mobileOptOut(e.detail.remember).catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); }); elem.addEventListener(DDGVideoOverlayMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { - return this.mobileOptIn(e.detail.remember, params).catch(console.error); + return this.mobileOptIn(e.detail.remember, params).catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); }); targetElement.appendChild(elem); @@ -289,7 +295,10 @@ export class VideoOverlay { drawer.text = mobileStrings(this.environment.strings('overlays.json')); drawer.addEventListener(DDGVideoDrawerMobile.OPEN_INFO, () => this.messages.openInfo()); drawer.addEventListener(DDGVideoDrawerMobile.OPT_OUT, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { - return this.mobileOptOut(e.detail.remember).catch(console.error); + return this.mobileOptOut(e.detail.remember).catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); }); drawer.addEventListener(DDGVideoDrawerMobile.DISMISS, () => { return this.dismissOverlay(); @@ -298,7 +307,10 @@ export class VideoOverlay { return this.dismissOverlay(); }); drawer.addEventListener(DDGVideoDrawerMobile.OPT_IN, (/** @type {CustomEvent<{remember: boolean}>} */ e) => { - return this.mobileOptIn(e.detail.remember, params).catch(console.error); + return this.mobileOptIn(e.detail.remember, params).catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); }); drawerTargetElement.appendChild(drawer); @@ -412,7 +424,10 @@ export class VideoOverlay { } return this.environment.setHref(params.toPrivatePlayerUrl()); }) - .catch((e) => console.error('error setting user choice', e)); + .catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); } /** @@ -437,10 +452,15 @@ export class VideoOverlay { overlayInteracted: true, }) .then((values) => { - this.userValues = values; + if (values) { + this.userValues = values; + this.watchForVideoBeingAdded({ ignoreCache: true, via: 'userOptOut' }); + } }) - .then(() => this.watchForVideoBeingAdded({ ignoreCache: true, via: 'userOptOut' })) - .catch((e) => console.error('could not set userChoice for opt-out', e)); + .catch((e) => { + console.error(e); + this.messages.metrics.reportExceptionWithError(e); + }); } else { this.messages.sendPixel(new Pixel({ name: 'play.do_not_use', remember: '0' })); this.destroy(); @@ -499,7 +519,9 @@ export class VideoOverlay { const updatedValues = await this.messages.setUserValues(next); // this is needed to ensure any future page navigations respect the new settings - this.userValues = updatedValues; + if (updatedValues) { + this.userValues = updatedValues; + } if (this.environment.debug) { console.log('user values response:', updatedValues); diff --git a/injected/unit-test/verify-artifacts.js b/injected/unit-test/verify-artifacts.js index 484228c184..586555d3e3 100644 --- a/injected/unit-test/verify-artifacts.js +++ b/injected/unit-test/verify-artifacts.js @@ -7,7 +7,7 @@ const ROOT = join(cwd(import.meta.url), '..', '..'); console.log(ROOT); const BUILD = join(ROOT, 'build'); -let CSS_OUTPUT_SIZE = 770_000; +let CSS_OUTPUT_SIZE = 780_000; if (process.platform === 'win32') { CSS_OUTPUT_SIZE = CSS_OUTPUT_SIZE * 1.1; // 10% larger for Windows due to line endings } diff --git a/messaging/lib/test-utils.mjs b/messaging/lib/test-utils.mjs index 3efa56543f..cf3753d4a7 100644 --- a/messaging/lib/test-utils.mjs +++ b/messaging/lib/test-utils.mjs @@ -292,7 +292,7 @@ export function mockAndroidMessaging(params) { * @param {string} secret * @return {Promise} */ - // eslint-disable-next-line require-await + process: async (jsonString, secret) => { /** @type {RequestMessage | NotificationMessage} */ const msg = JSON.parse(jsonString); @@ -305,6 +305,9 @@ export function mockAndroidMessaging(params) { ), ); + // force a 'tick' to allow tests to reset mocks before reading responses + await new Promise((resolve) => setTimeout(resolve, 0)); + // if it's a notification, simulate the empty response and don't check for a response if (!('id' in msg)) { return; diff --git a/metrics/docs/readme.md b/metrics/docs/readme.md new file mode 100644 index 0000000000..9b8544c33b --- /dev/null +++ b/metrics/docs/readme.md @@ -0,0 +1,16 @@ +--- +title: Metrics +--- + +# Metrics + +Utility class for reporting metrics and exceptions to the native layer. + +This class provides standardized methods for sending metric events and exception reports +through the messaging system. It includes predefined metric names for common error types +and helper methods to construct and send metric events. + +Please see [metrics-reporter.js](../metrics-reporter.js) +for the message schema + +{@includeCode ../examples/metrics.js} \ No newline at end of file diff --git a/metrics/examples/metrics.js b/metrics/examples/metrics.js new file mode 100644 index 0000000000..f847ba0e65 --- /dev/null +++ b/metrics/examples/metrics.js @@ -0,0 +1,39 @@ +import { MetricsReporter } from '../metrics-reporter.js'; +import { Messaging, MessagingContext, TestTransportConfig } from '@duckduckgo/messaging'; + +const messaging = createMessaging(); +const metrics = new MetricsReporter(messaging); + +// Report a custom metric +metrics.reportMetric({ + metricName: 'exception', + params: { kind: 'abc', message: 'something went' }, +}); + +// Report an exception +metrics.reportException({ + message: 'Failed to load user data', + kind: 'NetworkError', +}); + +// Report an exception by passing an Error object +metrics.reportExceptionWithError(new Error('Missing params')); + +// test messaging example +function createMessaging() { + const context = new MessagingContext({ + context: 'test', + env: 'development', + featureName: 'testFeature', + }); + const config = new TestTransportConfig({ + notify() {}, + request() { + return Promise.resolve(null); + }, + subscribe() { + return () => {}; + }, + }); + return new Messaging(context, config); +} diff --git a/metrics/messages/reportMetric.notify.json b/metrics/messages/reportMetric.notify.json new file mode 100644 index 0000000000..111faccebd --- /dev/null +++ b/metrics/messages/reportMetric.notify.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Report Metric Event", + "type": "object", + "oneOf": [ + { + "type": "object", + "title": "Exception Metric", + "required": ["metricName", "params"], + "properties": { + "metricName": { + "const": "exception" + }, + "params": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "kind": { + "type": "string" + } + } + } + } + } + ] +} diff --git a/metrics/metrics-reporter.js b/metrics/metrics-reporter.js new file mode 100644 index 0000000000..65dad034eb --- /dev/null +++ b/metrics/metrics-reporter.js @@ -0,0 +1,152 @@ +/** + * @typedef {import('./types/metrics.ts').ExceptionMetric} ExceptionMetric + * @typedef {import('./types/metrics.ts').ReportMetricEvent} ReportMetricEvent + */ +import { createTypedMessages } from '@duckduckgo/messaging/lib/typed-messages.js'; + +/** Exception kind for generic errors */ +export const EXCEPTION_KIND_GENERIC_ERROR = 'Error'; + +/** Exception kind for initialization errors */ +export const EXCEPTION_KIND_INIT_ERROR = 'InitError'; + +/** Exception kind for initial setup errors */ +export const EXCEPTION_KIND_INITIAL_SETUP_ERROR = 'InitialSetupError'; + +/** Exception kind for messaging-related errors */ +export const EXCEPTION_KIND_MESSAGING_ERROR = 'MessagingError'; + +/** + * Class for reporting metrics and exceptions to the native layer. + */ +export class MetricsReporter { + /** The message ID used for reporting metrics to the native layer */ + static MESSAGE_ID = /** @type {const} */ ('reportMetric'); + + /** The metric name used for exception reporting */ + static METRIC_NAME_EXCEPTION = /** @type {const} */ ('exception'); + + /** The default exception message */ + static DEFAULT_EXCEPTION_MESSAGE = /** @type {const} */ ('Unknown error'); + + /** + * Creates a new MetricsReporter instance. + * + * @param {import('@duckduckgo/messaging').Messaging} messaging - The messaging instance used to communicate with the native layer + * @throws {Error} When messaging is not provided or messaging.notify is not defined + */ + constructor(messaging) { + if (!messaging) { + throw new Error('messaging is required'); + } + this.messaging = createTypedMessages(this, messaging); + } + + /** + * Send a metric event to the native layer + * + * @param {ReportMetricEvent} metricEvent + * + * @throws {Error} When metricEvent.metricName is missing + * @private + */ + _sendMetric(metricEvent) { + if (!metricEvent?.metricName) { + throw new Error('metricName is required'); + } + this.messaging.notify(MetricsReporter.MESSAGE_ID, metricEvent); + } + + /** + * @typedef {Object} ExceptionMetricParams + * @property {string} [message] - The exception message + * @property {string} [kind] - The exception kind + */ + + /** + * Creates an exception metric object. If message or kind parameters are not provided, default values + * will be used. + * + * @param {ExceptionMetricParams} [params] - The exception parameters containing message and kind (both optional) + * @returns {ExceptionMetric} + * @private + */ + _createExceptionMetric(params) { + const message = params?.message && typeof params.message === 'string' ? params.message : MetricsReporter.DEFAULT_EXCEPTION_MESSAGE; + const kind = params?.kind && typeof params.kind === 'string' ? params.kind : EXCEPTION_KIND_GENERIC_ERROR; + + return { + metricName: MetricsReporter.METRIC_NAME_EXCEPTION, + params: { + message, + kind, + }, + }; + } + + /** + * Sends a standard `reportMetric` event to the native layer. + * + * @param {ReportMetricEvent} metricEvent - The metric event to report, must contain a metricName + * @throws {Error} When metricEvent.metricName is missing + * + * @example + * ```javascript + * metrics.reportMetric({ + * metricName: 'pageLoad', + * params: { duration: 1500, page: 'home' } + * }); + * ``` + */ + reportMetric(metricEvent) { + this._sendMetric(metricEvent); + } + + /** + * Sends a `reportMetric` event with metric name `exception`, getting `message` and `kind` from the `params` object. If no params object is passed, defaults are used. + * + * + * @param {ExceptionMetricParams} [params] - The exception parameters containing message and kind (both optional) + * + * @example + * ```javascript + * // Report an exception with custom message and kind + * metrics.reportException({ + * message: 'Failed to fetch user data from API', + * kind: 'NetworkError' + * }); + * + * // Report an exception with default values (message: 'Unknown error', kind: 'Error') + * metrics.reportException(); + * ``` + */ + reportException(params) { + const metric = this._createExceptionMetric(params); + this._sendMetric(metric); + } + + /** + * Sends a `reportMetric` event with metric name `exception`, getting message and kind from the error object. The `kind` property is inferred from `error.name`. + * + * If no error object is passed, a default error is reported. + * + * If an invalid error object is passed, nothing is reported. + * + * @param {Error} [error] - The error to report + * + * @example + * ```javascript + * const error = new Error('Failed to fetch user data from API'); + * error.name = 'NetworkError'; + * metrics.reportExceptionWithError(error); + * ``` + */ + reportExceptionWithError(error) { + if (error && !(error instanceof Error)) { + console.warn('reportExceptionWithError: error is not an Error object', error); + return; + } + const metric = this._createExceptionMetric({ message: error?.message, kind: error?.name }); + this._sendMetric(metric); + } +} diff --git a/metrics/package.json b/metrics/package.json new file mode 100644 index 0000000000..8ee31c5b5d --- /dev/null +++ b/metrics/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "metrics", + "type": "module", + "scripts": { + "build": "node scripts/types.mjs", + "test-unit": "node --test unit-test/*" + }, + "dependencies": { + "@duckduckgo/messaging": "*" + } +} \ No newline at end of file diff --git a/metrics/scripts/types.mjs b/metrics/scripts/types.mjs new file mode 100644 index 0000000000..d8eeb9aef5 --- /dev/null +++ b/metrics/scripts/types.mjs @@ -0,0 +1,32 @@ +import { cwd, isLaunchFile } from '../../scripts/script-utils.js'; +import { join } from 'node:path'; +import { buildTypes } from '../../types-generator/build-types.mjs'; + +const root = join(cwd(import.meta.url), '..'); + +/** @type {Record} */ +const injectSchemaMapping = { + /** + * Add more mappings here + * - `schema` should be an absolute path to a valid JSON Schema document + * - `types` should be an absolute path to the output file + */ + 'Metrics Messages': { + schemaDir: join(root, 'messages'), + typesDir: join(root, 'types'), + exclude: process.platform === 'win32', + kind: 'single', + resolve: (_dirname) => '../metrics-reporter.js', + className: (_top) => 'MetricsReporter', + filename: `metrics.ts`, + }, +}; + +if (isLaunchFile(import.meta.url)) { + buildTypes(injectSchemaMapping) + // eslint-disable-next-line promise/prefer-await-to-then + .catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/metrics/types/metrics.ts b/metrics/types/metrics.ts new file mode 100644 index 0000000000..0e8877b524 --- /dev/null +++ b/metrics/types/metrics.ts @@ -0,0 +1,36 @@ +/** + * These types are auto-generated from schema files. + * scripts/build-types.mjs is responsible for type generation. + * **DO NOT** edit this file directly as your changes will be lost. + * + * @module MetricsMessages Messages + */ + +export type ReportMetricEvent = ExceptionMetric; + +/** + * Requests, Notifications and Subscriptions from the MetricsMessages feature + */ +export interface MetricsMessagesMessages { + notifications: ReportMetricNotification; +} +/** + * Generated from @see "../messages/reportMetric.notify.json" + */ +export interface ReportMetricNotification { + method: "reportMetric"; + params: ReportMetricEvent; +} +export interface ExceptionMetric { + metricName: "exception"; + params: { + message: string; + kind?: string; + }; +} + +declare module "../metrics-reporter.js" { + export interface MetricsReporter { + notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'] + } +} \ No newline at end of file diff --git a/metrics/unit-test/metrics-reporter.mjs b/metrics/unit-test/metrics-reporter.mjs new file mode 100644 index 0000000000..5fd941a4d0 --- /dev/null +++ b/metrics/unit-test/metrics-reporter.mjs @@ -0,0 +1,120 @@ +import { describe, it, mock, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { MetricsReporter } from '../metrics-reporter.js'; + +describe('reportMetric', () => { + let messaging; + + beforeEach(() => { + // Create the mock inside beforeEach to ensure it's fresh for each test + messaging = /** @type {any} */ ({ + notify: mock.fn((params) => { + console.log('Notify called with', params); + }), + }); + }); + + it('should throw an error if messaging is not provided', () => { + // @ts-expect-error - this is a forced error + assert.throws(() => new MetricsReporter(null)); + }); + + it('should throw an error if metricName is not provided', () => { + const metrics = new MetricsReporter(messaging); + const metricParams = /** @type {any} */ ({ metricName: '', params: {} }); + assert.throws(() => metrics.reportMetric(metricParams)); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + }); + + it('should call messaging.notify with the correct parameters', () => { + const metrics = new MetricsReporter(messaging); + const metricParams = /** @type {any} */ ({ metricName: 'exception', params: { message: 'This is a test' } }); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportMetric(metricParams); + assert.strictEqual(messaging.notify.mock.callCount(), 1); + const call = messaging.notify.mock.calls[0]; + assert.deepEqual(call.arguments, [ + 'reportMetric', + { + metricName: 'exception', + params: { message: 'This is a test' }, + }, + ]); + }); + + it('should call messaging.notify when reportException is called', () => { + const metrics = new MetricsReporter(messaging); + const eventParams = /** @type {any} */ ({ message: 'This is a test', kind: 'TestError' }); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportException(eventParams); + assert.strictEqual(messaging.notify.mock.callCount(), 1); + const call = messaging.notify.mock.calls[0]; + assert.deepEqual(call.arguments, [ + 'reportMetric', + { + metricName: 'exception', + params: { message: 'This is a test', kind: 'TestError' }, + }, + ]); + }); + + it('should send default values when reportException is called with empty params', () => { + const metrics = new MetricsReporter(messaging); + const eventParams = /** @type {any} */ ({}); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportException(eventParams); + assert.strictEqual(messaging.notify.mock.callCount(), 1); + const call = messaging.notify.mock.calls[0]; + assert.deepEqual(call.arguments, [ + 'reportMetric', + { + metricName: 'exception', + params: { message: 'Unknown error', kind: 'Error' }, + }, + ]); + }); + + it('should not report anything when reportExceptionWithError is called with a non-Error object', () => { + const metrics = new MetricsReporter(messaging); + const eventParams = /** @type {any} */ ({ message: 'This is a test', kind: 'TestError' }); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportExceptionWithError(eventParams); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + }); + + it('should send the error message and kind when reportExceptionWithError is called with an Error object', () => { + const metrics = new MetricsReporter(messaging); + const error = new Error('This is a test'); + error.name = 'TestError'; + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportExceptionWithError(error); + assert.strictEqual(messaging.notify.mock.callCount(), 1); + const call = messaging.notify.mock.calls[0]; + assert.deepEqual(call.arguments, [ + 'reportMetric', + { + metricName: 'exception', + params: { message: 'This is a test', kind: 'TestError' }, + }, + ]); + }); + + it('should send default values when reportExceptionWithError is called with an empty error object', () => { + const metrics = new MetricsReporter(messaging); + const error = new Error(); + assert.strictEqual(messaging.notify.mock.callCount(), 0); + + metrics.reportExceptionWithError(error); + assert.strictEqual(messaging.notify.mock.callCount(), 1); + const call = messaging.notify.mock.calls[0]; + assert.deepEqual(call.arguments, [ + 'reportMetric', + { metricName: 'exception', params: { message: 'Unknown error', kind: 'Error' } }, + ]); + }); +}); diff --git a/package-lock.json b/package-lock.json index a94083ec60..910808d1b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "injected", "special-pages", "messaging", - "types-generator" + "types-generator", + "metrics" ], "dependencies": { "immutable-json-patch": "^6.0.1", @@ -62,6 +63,11 @@ "version": "1.0.0", "license": "ISC" }, + "metrics": { + "dependencies": { + "@duckduckgo/messaging": "*" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.7.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", @@ -7148,6 +7154,10 @@ "node": ">= 8" } }, + "node_modules/metrics": { + "resolved": "metrics", + "link": true + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", diff --git a/package.json b/package.json index c40707272e..f009248859 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "injected", "special-pages", "messaging", - "types-generator" + "types-generator", + "metrics" ], "devDependencies": { "@duckduckgo/eslint-config": "github:duckduckgo/eslint-config#v0.1.0", diff --git a/special-pages/pages/duckplayer/app/index.js b/special-pages/pages/duckplayer/app/index.js index 9b604739b1..8495e9de73 100644 --- a/special-pages/pages/duckplayer/app/index.js +++ b/special-pages/pages/duckplayer/app/index.js @@ -15,6 +15,7 @@ import { Components } from './components/Components.jsx'; import { MobileApp } from './components/MobileApp.jsx'; import { DesktopApp } from './components/DesktopApp.jsx'; import { YouTubeErrorProvider } from './providers/YouTubeErrorProvider'; +import { EXCEPTION_KIND_INIT_ERROR, EXCEPTION_KIND_INITIAL_SETUP_ERROR } from '../../../../metrics/metrics-reporter.js'; /** @typedef {import('../types/duckplayer').YouTubeError} YouTubeError */ @@ -27,11 +28,17 @@ import { YouTubeErrorProvider } from './providers/YouTubeErrorProvider'; export async function init(messaging, telemetry, baseEnvironment) { const result = await callWithRetry(() => messaging.initialSetup()); if ('error' in result) { - throw new Error(result.error); + const error = new Error(result.error); + error.name = EXCEPTION_KIND_INITIAL_SETUP_ERROR; + throw error; } const init = result.value; - console.log('INITIAL DATA', init); + if (!init) { + const error = new Error('missing initialSetup data'); + error.name = EXCEPTION_KIND_INITIAL_SETUP_ERROR; + throw error; + } // update the 'env' in case it was changed by native sides const environment = baseEnvironment @@ -66,17 +73,32 @@ export async function init(messaging, telemetry, baseEnvironment) { console.log(settings); - const embed = createEmbedSettings(window.location.href, settings); + let embed = null; + try { + embed = createEmbedSettings(window.location.href, settings); + if (!embed) { + throw new Error('Embed not found'); + } + } catch (e) { + messaging.metrics.reportException({ message: e.message, kind: EXCEPTION_KIND_INIT_ERROR }); + } const didCatch = (error) => { - const message = error?.message || 'unknown'; - messaging.reportPageException({ message }); + const message = error?.message; + messaging.metrics.reportExceptionWithError(error?.error); + + // TODO: Remove the following event once all native platforms are responding to 'reportMetric: exception' + messaging.reportPageException({ message: message || 'unknown error' }); }; document.body.dataset.layout = settings.layout; const root = document.querySelector('body'); - if (!root) throw new Error('could not render, root element missing'); + if (!root) { + const error = new Error('could not render, root element missing'); + error.name = EXCEPTION_KIND_INIT_ERROR; + throw error; + } if (environment.display === 'app') { render( diff --git a/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx b/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx index e0bb2b2850..73cf9fbbf0 100644 --- a/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx +++ b/special-pages/pages/duckplayer/app/providers/UserValuesProvider.jsx @@ -60,6 +60,8 @@ export function UserValuesProvider({ initial, children }) { }) .catch((err) => { console.error('could not set the enabled flag', err); + + // TODO: Remove the following event once all native platforms are responding to 'reportMetric: exception' messaging.reportPageException({ message: 'could not set the enabled flag: ' + err.toString() }); }); } diff --git a/special-pages/pages/duckplayer/integration-tests/duck-player.js b/special-pages/pages/duckplayer/integration-tests/duck-player.js index 51460d6f6a..c8032fa1e3 100644 --- a/special-pages/pages/duckplayer/integration-tests/duck-player.js +++ b/special-pages/pages/duckplayer/integration-tests/duck-player.js @@ -145,6 +145,31 @@ export class DuckPlayerPage { this.mocks.defaultResponses(clone); } + /** + * Simulates a messaging error by passing an empty initialSetup object + */ + messagingError() { + const clone = structuredClone(this.defaults); + + this.build.switch({ + android: () => { + // @ts-expect-error - this is a test + clone.initialSetup = {}; + this.mocks.defaultResponses(clone); + }, + apple: () => { + // @ts-expect-error - this is a test + clone.initialSetup = null; + this.mocks.defaultResponses(clone); + }, + windows: () => { + // @ts-expect-error - this is a test + clone.initialSetup = ''; + this.mocks.defaultResponses(clone); + }, + }); + } + /** * We don't need to actually load the content for these tests. * By mocking the response, we make the tests about 10x faster and also ensure they work offline. @@ -259,6 +284,11 @@ export class DuckPlayerPage { await this.openPage(params); } + async openWithNoEmbed() { + const params = new URLSearchParams({ videoID: '' }); + await this.openPage(params); + } + /** * @param {string} [videoID] * @returns {Promise} @@ -537,6 +567,44 @@ export class DuckPlayerPage { ]); } + /** + * @param {import('../../../../metrics/types/metrics.js').ReportMetricEvent} evt + */ + async didSendReportMetric(evt) { + const events = await this.mocks.waitForCallCount({ method: 'reportMetric', count: 1 }); + expect(events).toContainEqual({ + payload: { + context: 'specialPages', + featureName: 'duckPlayerPage', + method: 'reportMetric', + params: evt, + }, + }); + } + + /** + * @param {string} kind + * @param {string} message + */ + didSendException(kind, message) { + return this.didSendReportMetric({ metricName: 'exception', params: { kind, message } }); + } + + async didSendMessagingException() { + await this.build.switch({ + android: async () => { + // Android produces a TypeError due to how its messaging lib is wired up + await this.didSendException('TypeError', "undefined is not an object (evaluating 'init2.settings.pip')"); + }, + apple: async () => { + await this.didSendException('MessagingError', 'an unknown error occurred'); + }, + windows: async () => { + await this.didSendException('MessagingError', 'unknown error'); + }, + }); + } + async withStorageValues() { await this.page.evaluate(() => { localStorage.setItem('foo', 'bar'); diff --git a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js index 7d3a1155e1..da58715f8e 100644 --- a/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js +++ b/special-pages/pages/duckplayer/integration-tests/duckplayer.spec.js @@ -333,6 +333,20 @@ test.describe('reporting exceptions', () => { // load as normal await duckplayer.openWithException(); await duckplayer.showsErrorMessage(); + await duckplayer.didSendException('Error', 'Simulated Exception'); + }); + test('no embed error', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + // load as normal + await duckplayer.openWithNoEmbed(); + await duckplayer.didSendException('InitError', 'Embed not found'); + }); + test('initial setup error', async ({ page }, workerInfo) => { + const duckplayer = DuckPlayerPage.create(page, workerInfo); + // load as normal + duckplayer.messagingError(); + await duckplayer.openWithVideoID(); + await duckplayer.didSendMessagingException(); }); }); diff --git a/special-pages/pages/duckplayer/src/index.js b/special-pages/pages/duckplayer/src/index.js index c677b427ea..69087cc856 100644 --- a/special-pages/pages/duckplayer/src/index.js +++ b/special-pages/pages/duckplayer/src/index.js @@ -4,6 +4,7 @@ import { createSpecialPageMessaging } from '../../../shared/create-special-page- import { init } from '../app/index.js'; import { initStorage } from './storage.js'; import '../../../shared/live-reload.js'; +import { MetricsReporter, EXCEPTION_KIND_MESSAGING_ERROR } from '../../../../metrics/metrics-reporter.js'; export class DuckplayerPage { /** @@ -12,14 +13,15 @@ export class DuckplayerPage { constructor(messaging, injectName) { this.messaging = createTypedMessages(this, messaging); this.injectName = injectName; + this.metrics = new MetricsReporter(messaging); } /** * This will be sent if the application has loaded, but a client-side error * has occurred that cannot be recovered from - * @returns {Promise} + * @returns {Promise} */ - initialSetup() { + async initialSetup() { if (this.injectName === 'integration') { return Promise.resolve({ platform: { name: 'ios' }, @@ -36,7 +38,12 @@ export class DuckplayerPage { locale: 'en', }); } - return this.messaging.request('initialSetup'); + try { + return await this.messaging.request('initialSetup'); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** @@ -44,8 +51,13 @@ export class DuckplayerPage { * * @param {import("../types/duckplayer.ts").UserValues} userValues */ - setUserValues(userValues) { - return this.messaging.request('setUserValues', userValues); + async setUserValues(userValues) { + try { + return await this.messaging.request('setUserValues', userValues); + } catch (e) { + this.metrics.reportException({ message: e?.message, kind: EXCEPTION_KIND_MESSAGING_ERROR }); + return null; + } } /** @@ -117,6 +129,8 @@ export class DuckplayerPage { } } +// TODO: Remove telemetry + /** * Events that occur in the client-side application */ @@ -178,10 +192,12 @@ const duckplayerPage = new DuckplayerPage(messaging, import.meta.injectName); const telemetry = new Telemetry(messaging); init(duckplayerPage, telemetry, baseEnvironment).catch((e) => { - // messages. console.error(e); - const msg = typeof e?.message === 'string' ? e.message : 'unknown init error'; - duckplayerPage.reportInitException({ message: msg }); + duckplayerPage.metrics.reportExceptionWithError(e); + + // TODO: Remove this event once all native platforms are responding to 'reportMetric: exception' + const message = typeof e?.message === 'string' ? e.message : 'unknown error'; + duckplayerPage.reportInitException({ message }); }); initStorage(); diff --git a/tsconfig.json b/tsconfig.json index 6a96819b17..ddc9fddbc4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "types-generator", "special-pages", "messaging", + "metrics", "playwright.config.js", "injected", "injected/src/globals.d.ts", diff --git a/typedoc.js b/typedoc.js index efc432c7db..1f2a20a36f 100644 --- a/typedoc.js +++ b/typedoc.js @@ -7,6 +7,7 @@ const config = { 'special-pages/pages/new-tab/app/new-tab.md', 'special-pages/pages/history/app/history.md', 'injected/docs/*.md', + 'metrics/docs/*.md', 'messaging/docs/messaging.md', ], @@ -35,6 +36,7 @@ const config = { 'special-pages/pages/special-error/app/types.js', 'special-pages/pages/new-tab/app/favorites/constants.js', 'special-pages/pages/**/types/*.ts', + 'metrics/metrics-reporter.js', ], categoryOrder: ['Special Pages', 'Content Scope Scripts Integrations', 'Other'], out: 'docs',