diff --git a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js index 04ff15dee..268de0033 100644 --- a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js +++ b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js @@ -1,7 +1,7 @@ import { h } from 'preact'; import { useContext } from 'preact/hooks'; -import { useTypedTranslation } from '../../types.js'; +import { useTelemetry, useTypedTranslation } from '../../types.js'; import { useVisibility } from '../../widget-list/widget-config.provider.js'; import { useCustomizer } from '../../customizer/Customizer.js'; @@ -14,8 +14,10 @@ import { FavoritesMemo } from './Favorites.js'; */ export function FavoritesConsumer() { const { state, toggle, favoritesDidReOrder, openContextMenu, openFavorite, add } = useContext(FavoritesContext); + const telemetry = useTelemetry(); if (state.status === 'ready') { + telemetry.measureFromPageLoad('favorites-will-render', 'time to favorites'); return ( - - - - - - - + + + + + + + + + diff --git a/special-pages/pages/new-tab/app/telemetry/Debug.js b/special-pages/pages/new-tab/app/telemetry/Debug.js new file mode 100644 index 000000000..a76636771 --- /dev/null +++ b/special-pages/pages/new-tab/app/telemetry/Debug.js @@ -0,0 +1,64 @@ +import { h } from 'preact'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useTelemetry } from '../types.js'; +import { useCustomizer } from '../customizer/Customizer.js'; +import { Telemetry } from './telemetry.js'; + +export function DebugCustomized({ index }) { + const [isOpen, setOpen] = useState(false); + const telemetry = useTelemetry(); + useCustomizer({ + title: '🐞 Debug', + id: 'debug', + icon: 'shield', + visibility: isOpen ? 'visible' : 'hidden', + + toggle: (_id) => setOpen((prev) => !prev), + index, + }); + return ( +
+ +
+ ); +} + +/** + * @param {object} props + * @param {import("./telemetry.js").Telemetry} props.telemetry + * @param {boolean} props.isOpen + */ +export function Debug({ telemetry, isOpen }) { + /** @type {import("preact").Ref} */ + const textRef = useRef(null); + useEvents(textRef, telemetry); + return ( + + ); +} + +/** + * @param {import("preact").RefObject} ref + * @param {import("./telemetry.js").Telemetry} telemetry + */ +function useEvents(ref, telemetry) { + useEffect(() => { + if (!ref.current) return; + const elem = ref.current; + function handle(/** @type {CustomEvent} */ { detail }) { + elem.value += JSON.stringify(detail, null, 2) + '\n\n'; + } + for (const beforeElement of telemetry.eventStore) { + elem.value += JSON.stringify(beforeElement, null, 2) + '\n\n'; + } + telemetry.eventStore = []; + telemetry.storeEnabled = false; + telemetry.eventTarget.addEventListener(Telemetry.EVENT_BROADCAST, handle); + return () => { + telemetry.eventTarget.removeEventListener(Telemetry.EVENT_BROADCAST, handle); + telemetry.storeEnabled = true; + }; + }, [ref, telemetry]); +} diff --git a/special-pages/pages/new-tab/app/telemetry/telemetry.js b/special-pages/pages/new-tab/app/telemetry/telemetry.js new file mode 100644 index 000000000..857f96fb7 --- /dev/null +++ b/special-pages/pages/new-tab/app/telemetry/telemetry.js @@ -0,0 +1,179 @@ +/** + * @import { Messaging } from "@duckduckgo/messaging" + */ +export class Telemetry { + static EVENT_REQUEST = 'TELEMETRY_EVENT_REQUEST'; + static EVENT_RESPONSE = 'TELEMETRY_EVENT_RESPONSE'; + static EVENT_SUBSCRIPTION = 'TELEMETRY_EVENT_SUBSCRIPTION'; + static EVENT_SUBSCRIPTION_DATA = 'TELEMETRY_EVENT_SUBSCRIPTION_DATA'; + static EVENT_NOTIFICATION = 'TELEMETRY_EVENT_NOTIFICATION'; + static EVENT_BROADCAST = 'TELEMETRY_*'; + + eventTarget = new EventTarget(); + /** @type {any[]} */ + eventStore = []; + storeEnabled = true; + + /** + * @param now + */ + constructor(now = Date.now()) { + this.now = now; + performance.mark('ddg-telemetry-init'); + this._setupMessagingMarkers(); + } + + _setupMessagingMarkers() { + this.eventTarget.addEventListener(Telemetry.EVENT_REQUEST, (/** @type {CustomEvent} */ { detail }) => { + const named = `ddg request ${detail.method} ${detail.timestamp}`; + performance.mark(named); + this.broadcast(detail); + }); + this.eventTarget.addEventListener(Telemetry.EVENT_RESPONSE, (/** @type {CustomEvent} */ { detail }) => { + const reqNamed = `ddg request ${detail.method} ${detail.timestamp}`; + const resNamed = `ddg response ${detail.method} ${detail.timestamp}`; + performance.mark(resNamed); + performance.measure(reqNamed, reqNamed, resNamed); + this.broadcast(detail); + }); + this.eventTarget.addEventListener(Telemetry.EVENT_SUBSCRIPTION, (/** @type {CustomEvent} */ { detail }) => { + const named = `ddg subscription ${detail.method} ${detail.timestamp}`; + performance.mark(named); + this.broadcast(detail); + }); + this.eventTarget.addEventListener(Telemetry.EVENT_SUBSCRIPTION_DATA, (/** @type {CustomEvent} */ { detail }) => { + const named = `ddg subscription data ${detail.method} ${detail.timestamp}`; + performance.mark(named); + this.broadcast(detail); + }); + this.eventTarget.addEventListener(Telemetry.EVENT_NOTIFICATION, (/** @type {CustomEvent} */ { detail }) => { + const named = `ddg notification ${detail.method} ${detail.timestamp}`; + performance.mark(named); + this.broadcast(detail); + }); + } + + broadcast(payload) { + if (this.eventStore.length >= 50) { + this.eventStore = []; + } + if (this.storeEnabled) { + this.eventStore.push(structuredClone(payload)); + } + this.eventTarget.dispatchEvent(new CustomEvent(Telemetry.EVENT_BROADCAST, { detail: payload })); + } + + measureFromPageLoad(marker, measure = 'measure__' + Date.now()) { + if (!performance.getEntriesByName(marker).length) { + performance.mark(marker); + performance.measure(measure, 'ddg-telemetry-init', marker); + } + } +} + +/** + * @implements Messaging + */ +class MessagingObserver { + /** @type {Map} */ + observed = new Map(); + + /** + * @param {import("@duckduckgo/messaging").Messaging} messaging + * @param {EventTarget} eventTarget + */ + constructor(messaging, eventTarget) { + this.messaging = messaging; + this.messagingContext = messaging.messagingContext; + this.transport = messaging.transport; + this.eventTarget = eventTarget; + } + + /** + * @param {string} method + * @param {Record} params + */ + request(method, params) { + const timestamp = Date.now(); + const json = { + kind: 'request', + method, + params, + timestamp, + }; + this.record(Telemetry.EVENT_REQUEST, json); + return ( + this.messaging + .request(method, params) + // eslint-disable-next-line promise/prefer-await-to-then + .then((x) => { + const resJson = { + kind: 'response', + method, + result: x, + timestamp, + }; + this.record(Telemetry.EVENT_RESPONSE, resJson); + return x; + }) + ); + } + + /** + * @param {string} method + * @param {Record} params + */ + notify(method, params) { + const json = { + kind: 'notification', + method, + params, + }; + this.record(Telemetry.EVENT_NOTIFICATION, json); + return this.messaging.notify(method, params); + } + + /** + * @param method + * @param callback + * @return {function(): void} + */ + subscribe(method, callback) { + const timestamp = Date.now(); + const json = { + kind: 'subscription', + method, + timestamp, + }; + + this.record(Telemetry.EVENT_SUBSCRIPTION, json); + return this.messaging.subscribe(method, (params) => { + const json = { + kind: 'subscription data', + method, + timestamp, + params, + }; + this.record(Telemetry.EVENT_SUBSCRIPTION_DATA, json); + callback(params); + }); + } + + /** + * @param {string} name + * @param {Record} detail + */ + record(name, detail) { + this.eventTarget.dispatchEvent(new CustomEvent(name, { detail })); + } +} + +/** + * @param {Messaging} messaging + * @return {{telemetry: Telemetry, messaging: MessagingObserver}} + */ +export function install(messaging) { + const telemetry = new Telemetry(); + const observedMessaging = new MessagingObserver(messaging, telemetry.eventTarget); + return { telemetry, messaging: observedMessaging }; +} diff --git a/special-pages/pages/new-tab/app/types.js b/special-pages/pages/new-tab/app/types.js index 710c09338..dd0acda24 100644 --- a/special-pages/pages/new-tab/app/types.js +++ b/special-pages/pages/new-tab/app/types.js @@ -20,6 +20,12 @@ export function useTypedTranslation() { export const MessagingContext = createContext(/** @type {import("../src/js/index.js").NewTabPage} */ ({})); export const useMessaging = () => useContext(MessagingContext); +export const TelemetryContext = createContext( + /** @type {import("./telemetry/telemetry.js").Telemetry} */ ({ + measureFromPageLoad: () => {}, + }), +); +export const useTelemetry = () => useContext(TelemetryContext); export const InitialSetupContext = createContext(/** @type {InitialSetupResponse} */ ({})); export const useInitialSetupData = () => useContext(InitialSetupContext); diff --git a/special-pages/pages/new-tab/app/widget-list/WidgetList.js b/special-pages/pages/new-tab/app/widget-list/WidgetList.js index 411c02914..aa21f36e8 100644 --- a/special-pages/pages/new-tab/app/widget-list/WidgetList.js +++ b/special-pages/pages/new-tab/app/widget-list/WidgetList.js @@ -3,6 +3,8 @@ import { WidgetConfigContext, WidgetVisibilityProvider } from './widget-config.p import { useContext } from 'preact/hooks'; import { Stack } from '../../../onboarding/app/components/Stack.js'; import { Customizer, CustomizerMenuPositionedFixed } from '../customizer/Customizer.js'; +import { useEnv } from '../../../../shared/components/EnvironmentProvider.js'; +import { DebugCustomized } from '../telemetry/Debug.js'; /** * @param {string} id @@ -36,6 +38,7 @@ export async function widgetEntryPoint(id) { export function WidgetList() { const { widgets, widgetConfigItems, entryPoints } = useContext(WidgetConfigContext); + const { env } = useEnv(); return ( @@ -53,6 +56,7 @@ export function WidgetList() { ); })} + {env === 'development' && } diff --git a/special-pages/pages/new-tab/src/js/index.js b/special-pages/pages/new-tab/src/js/index.js index ee7c6927c..744c03843 100644 --- a/special-pages/pages/new-tab/src/js/index.js +++ b/special-pages/pages/new-tab/src/js/index.js @@ -9,6 +9,7 @@ import { createTypedMessages } from '@duckduckgo/messaging'; import { createSpecialPageMessaging } from '../../../../shared/create-special-page-messaging'; import { Environment } from '../../../../shared/environment.js'; import { mockTransport } from './mock-transport.js'; +import { install } from '../../app/telemetry/telemetry.js'; export class NewTabPage { /** @@ -57,7 +58,7 @@ export class NewTabPage { const baseEnvironment = new Environment().withInjectName(import.meta.injectName).withEnv(import.meta.env); -const messaging = createSpecialPageMessaging({ +const rawMessaging = createSpecialPageMessaging({ injectName: import.meta.injectName, env: import.meta.env, pageName: 'newTabPage', @@ -71,9 +72,10 @@ const messaging = createSpecialPageMessaging({ }, }); +const { messaging, telemetry } = install(rawMessaging); const newTabMessaging = new NewTabPage(messaging, import.meta.injectName); -init(newTabMessaging, baseEnvironment).catch((e) => { +init(newTabMessaging, telemetry, baseEnvironment).catch((e) => { console.error(e); const msg = typeof e?.message === 'string' ? e.message : 'unknown init error'; newTabMessaging.reportInitException(msg);