UNPKG

chrome-devtools-frontend

Version:
688 lines (641 loc) • 27.1 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../../ui/legacy/components/data_grid/data_grid.js'; import '../../../ui/kit/kit.js'; import '../../../ui/components/tooltips/tooltips.js'; import * as Common from '../../../core/common/common.js'; import * as i18n from '../../../core/i18n/i18n.js'; import type * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as Protocol from '../../../generated/protocol.js'; import * as Components from '../../../ui/legacy/components/utils/utils.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import adScriptsTableStyles from './adScriptsTable.css.js'; import adsViewStyles from './adsView.css.js'; const {html} = Lit; const {repeat} = Lit.Directives; const {bindToSetting} = UI.UIUtils; const UIStrings = { /** * @description Title for the metrics table. */ metrics: 'Metrics', /** * @description Title for a metric showing the percentage of the viewport covered by ads. */ viewportAdDensity: 'Viewport ad density', /** * @description Title for a metric showing the number of ads in the viewport. */ viewportAdCount: 'Viewport ad count', /** * @description Title for a metric showing the total CPU usage by ads. */ totalCpuUsage: 'Total CPU usage by ads', /** * @description Title for a metric showing the total network usage by ads. */ totalNetworkUsage: 'Total network usage by ads', /** * @description Subtext showing the average value of a metric. * @example {5.00%} PH1 */ average: '(Average: {PH1})', /** * @description Title for the ad iframes table. * @example {3} PH1 */ adIframesTitle: 'Ad iframes (total {PH1})', /** * @description Text to display when a value is not available. */ notAvailable: 'N/A', /** * @description Text to display when a frame has no name/id. */ unnamed: '<unnamed>', /** * @description Title for the Element Id column in the ad iframes table. */ elementId: 'Element ID', /** * @description Title for the Initial origin column in the ad iframes table. */ initialOrigin: 'Initial origin', /** * @description Title for the CPU column in the ad iframes table. */ cpu: 'CPU', /** * @description Title for the Network column in the ad iframes table. */ network: 'Network', /** * @description Accessible name for the ad iframes table. */ adIframes: 'Ad iframes', /** * @description Title for the ad scripts table. * @example {3} PH1 */ adScriptsTitle: 'Ad scripts (total {PH1})', /** * @description Title for the URL column in the ad scripts table. */ url: 'URL', /** * @description Accessible name for the ad scripts table. */ adScripts: 'Ad scripts', /** * @description Title for the ad provenance column in the ad scripts table. */ adProvenance: 'Ad provenance', /** * @description Text to display when a script has no provenance. */ noProvenance: '<no provenance>', /** * @description Text to display in the tooltip when a script has no provenance. */ noProvenanceTooltip: 'No provenance data is available', /** * @description Title for the filter list rule in the ad provenance tooltip. */ filterListRule: 'Filter list rule', /** * @description Title for the root script filter list rule in the ad provenance tooltip. */ rootScriptFilterListRule: 'Root script filter list rule', /** * @description Title for the creator ad script ancestry in the ad provenance tooltip. */ creatorAdScriptAncestry: 'Creator ad script ancestry', /** * @description Title for the settings section. */ settings: 'Settings', /** * @description The name of a checkbox setting. This setting highlights the * rendering elements for ads that are found on the page. */ highlightAds: 'Highlight ads', /** * @description Explanation text for the 'Highlight ads' setting. */ highlightsElementsRedDetectedToBe: 'Highlights elements (red) detected to be ads.', /** * @description Text explaining that ad detection is not perfect. */ adDetectionMistakes: 'Chrome’s ad detection can make mistakes.', /** * @description Link text for learning more about ad detection in Chrome. */ learnMore: 'Learn more', } as const; const str_ = i18n.i18n.registerUIStrings('panels/application/components/AdsView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * @description Data for a single row in the ad iframes table. */ interface AdFrameNodeData { elementId: string; initialOrigin: string; networkBytes: string; rawNetworkBytes: number; cpuTime: string; rawCpuTime: number; revealFrame: (e: Event) => void; } interface AdScriptNodeData { url: Platform.DevToolsPath.UrlString; parsedProvenance: Protocol.Network.AdProvenance|null; scriptId: Protocol.Runtime.ScriptId; } export interface ViewInput { metrics: Protocol.Ads.AdMetrics; adFrames: AdFrameNodeData[]; adScripts: AdScriptNodeData[]; target: SDK.Target.Target|null; getLinkElement: (url: string) => HTMLElement; } export type View = (input: ViewInput, output: undefined, target: HTMLElement|DocumentFragment) => void; const formatMetric = (val: number|undefined, formatter: (val: number) => string): string => { if (val === undefined || val === -1) { return i18nString(UIStrings.notAvailable); } return formatter(val); }; const formatCpu = (val: number|undefined): string => { return formatMetric(val, (v: number): string => i18n.TimeUtilities.millisToString(v)); }; const formatNetwork = (val: number|undefined): string => { return formatMetric(val, (v: number): string => i18n.ByteUtilities.bytesToString(v)); }; const SCRIPT_LINK_OPTIONS = { jslogContext: 'ad-script', }; const stopPropagation = (e: Event): void => e.stopPropagation(); const DEFAULT_VIEW: View = (input, output, target) => { const metrics = input.metrics; const formatValue = (val: number, isPercentage: boolean): string => { return formatMetric(val, (v: number): string => { if (isPercentage) { return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { style: 'percent', maximumFractionDigits: 0, }) .format(v / 100); } return new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale).format(v); }); }; const formatAverage = (val: number|undefined, isPercentage: boolean): string => { return formatMetric(val, (v: number): string => { if (isPercentage) { return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(v / 100); } return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(v); }); }; // clang-format off Lit.render(html` <style>${adsViewStyles}</style> <div class="ads-view-container" jslog=${VisualLogging.pane('ads')}> <div class="metrics-title">${i18nString(UIStrings.metrics)}</div> <dl class="metrics-container"> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.viewportAdDensity)}</dt> <dd class="metric-value"> <span>${formatValue(metrics.viewportAdDensityByArea, true)}</span> <span class="metric-average">${i18nString(UIStrings.average, { PH1: formatAverage(metrics.averageViewportAdDensityByArea, true), })}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.viewportAdCount)}</dt> <dd class="metric-value"> <span>${formatValue(metrics.viewportAdCount, false)}</span> <span class="metric-average">${i18nString(UIStrings.average, { PH1: formatAverage(metrics.averageViewportAdCount, false), })}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.totalCpuUsage)}</dt> <dd class="metric-value"> <span>${formatCpu(metrics.totalAdCpuTime)}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.totalNetworkUsage)}</dt> <dd class="metric-value"> <span>${formatNetwork(metrics.totalAdNetworkBytes)}</span> </dd> </div> </dl> <hr class="divider"> <div class="ad-frames-title">${i18nString(UIStrings.adIframesTitle, {PH1: input.adFrames.length})}</div> <div class="ad-frames-container"> <devtools-data-grid striped resize="last" class="ad-frames-data-grid" name=${i18nString(UIStrings.adIframes)}> <table> <tr> <th id="elementId" weight="1" sortable>${i18nString(UIStrings.elementId)}</th> <th id="initialOrigin" weight="2" sortable>${i18nString(UIStrings.initialOrigin)}</th> <th id="cpuTime" weight="1" sortable type="numeric">${i18nString(UIStrings.cpu)}</th> <th id="networkBytes" weight="1" sortable type="numeric">${i18nString(UIStrings.network)}</th> </tr> ${input.adFrames.map(frame => html` <tr> <td title=${frame.elementId}> ${frame.elementId ? html` <button class="text-button link-style devtools-link" @click=${frame.revealFrame}> ${frame.elementId} </button> ` : Lit.nothing} </td> <td title=${frame.initialOrigin}>${frame.initialOrigin}</td> <td title=${frame.cpuTime} data-value=${frame.rawCpuTime}>${frame.cpuTime}</td> <td title=${frame.networkBytes} data-value=${frame.rawNetworkBytes}>${frame.networkBytes}</td> </tr> `)} </table> </devtools-data-grid> </div> <hr class="divider"> <div class="ad-scripts-title">${i18nString(UIStrings.adScriptsTitle, {PH1: input.adScripts.length})}</div> <div class="ad-scripts-container"> <devtools-data-grid striped resize="last" class="ad-scripts-data-grid" name=${i18nString(UIStrings.adScripts)}> <table> ${Lit.Directives.unsafeHTML(`<style>${adScriptsTableStyles}</style>`)} <tr> <th id="url" weight="1" sortable>${i18nString(UIStrings.url)}</th> <th id="provenance" weight="1" sortable>${i18nString(UIStrings.adProvenance)}</th> </tr> ${repeat(input.adScripts, script => script.url, script => html` <tr> <td title=${script.url}> ${input.getLinkElement(script.url)} </td> <td> <devtools-tooltip id=${`ad-tooltip-${script.scriptId}`} variant=rich @copy=${stopPropagation}> <div class="ad-provenance-tooltip"> ${script.parsedProvenance?.filterlistRule ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.filterListRule)}</div> <div class="ad-provenance-tooltip-content">${script.parsedProvenance.filterlistRule}</div> ` : Lit.nothing} ${script.parsedProvenance?.adScriptAncestry ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.creatorAdScriptAncestry)}</div> <div class="ad-provenance-tooltip-content"> ${input.target ? script.parsedProvenance.adScriptAncestry.ancestryChain.map(ancestor => html` <div> ${UI.Widget.widget(Components.Linkifier.ScriptLocationLink, { target: input.target ?? undefined, scriptId: ancestor.scriptId, options: SCRIPT_LINK_OPTIONS, })} </div> `) : Lit.nothing} </div> ${script.parsedProvenance.adScriptAncestry.rootScriptFilterlistRule ? html` <div class="ad-provenance-tooltip-title">${i18nString(UIStrings.rootScriptFilterListRule)}</div> <div class="ad-provenance-tooltip-content">${script.parsedProvenance.adScriptAncestry.rootScriptFilterlistRule}</div> ` : Lit.nothing} ` : Lit.nothing} ${!script.parsedProvenance?.adScriptAncestry && !script.parsedProvenance?.filterlistRule ? i18nString(UIStrings.noProvenanceTooltip) : Lit.nothing} </div> </devtools-tooltip> <div aria-details=${`ad-tooltip-${script.scriptId}`}> ${script.parsedProvenance?.filterlistRule ? html`<span>${script.parsedProvenance.filterlistRule}</span>` : Lit.nothing} ${script.parsedProvenance?.filterlistRule && script.parsedProvenance?.adScriptAncestry && input.target ? html`<span>, </span>` : Lit.nothing} ${script.parsedProvenance?.adScriptAncestry && input.target ? UI.Widget.widget(Components.Linkifier.ScriptLocationLink, { target: input.target ?? undefined, scriptId: script.parsedProvenance.adScriptAncestry.ancestryChain[0].scriptId, options: SCRIPT_LINK_OPTIONS, }) : Lit.nothing} ${!script.parsedProvenance?.adScriptAncestry && !script.parsedProvenance?.filterlistRule ? i18nString(UIStrings.noProvenance) : Lit.nothing} </div> </td> </tr> `)} </table> </devtools-data-grid> </div> <hr class="divider"> <div class="settings-title">${i18nString(UIStrings.settings)}</div> <devtools-checkbox class="setting-container small" ${bindToSetting(Common.Settings.Settings.instance().resolve(SDK.SDKSettings.showAdHighlightsSettingDescriptor))}> <div class="setting-text-container"> <div class="setting-label">${i18nString(UIStrings.highlightAds)}</div> <div class="setting-explanation">${i18nString(UIStrings.highlightsElementsRedDetectedToBe)}</div> </div> </devtools-checkbox> <hr class="divider"> <div class="footer-text"> <devtools-icon class="inline-icon" name="info"></devtools-icon> &#32; <span> ${i18nString(UIStrings.adDetectionMistakes)} &#32; <devtools-link class="link devtools-link" href="https://chromium.googlesource.com/chromium/src/+/main/docs/ad_tagging.md" jslogcontext="learn-more"> ${i18nString(UIStrings.learnMore)} </devtools-link> </span> </div> </div> `, target); // clang-format on }; export class AdsView extends UI.Widget.Widget { #currentMetrics: Protocol.Ads.AdMetrics; #pollTimer?: number; #isPolling = false; #pollSessionId = 0; #view: View; readonly #adFrames = new Map<Protocol.Page.FrameId, Protocol.Ads.AdFrameData>(); readonly #adIframeElementIds = new Map<Protocol.Page.FrameId, string|null>(); readonly #fetchingElementIds = new Set<Protocol.Page.FrameId>(); readonly #unresolvedScriptIds = new Set<Protocol.Runtime.ScriptId>(); readonly #adScriptNodeData: AdScriptNodeData[] = []; readonly #urlToLinkElement = new Map<string, HTMLElement>(); readonly #reconstructedProvenance = new Map<Protocol.Runtime.ScriptId, Protocol.Network.AdProvenance>(); constructor(view: View = DEFAULT_VIEW) { super({useShadowDom: true}); this.#view = view; this.#currentMetrics = { viewportAdDensityByArea: 0, averageViewportAdDensityByArea: 0, viewportAdCount: 0, averageViewportAdCount: 0, totalAdCpuTime: 0, totalAdNetworkBytes: 0, updateAdFrames: [], removeAdFrames: [], }; this.requestUpdate(); } override wasShown(): void { super.wasShown(); this.#startPolling(); SDK.TargetManager.TargetManager.instance().addModelListener(SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); } override willHide(): void { this.#stopPolling(); SDK.TargetManager.TargetManager.instance().removeModelListener(SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); super.willHide(); } #startPolling(): void { if (this.#isPolling) { return; } this.#isPolling = true; this.#pollSessionId++; void this.#pollMetrics(this.#pollSessionId); } #stopPolling(): void { this.#isPolling = false; if (this.#pollTimer !== undefined) { window.clearTimeout(this.#pollTimer); this.#pollTimer = undefined; } } async #pollMetrics(sessionId: number): Promise<void> { if (!this.#isPolling || this.#pollSessionId !== sessionId) { return; } const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (target) { const adsAgent = target.adsAgent(); if (adsAgent) { const [metricsResponse, scriptsResponse] = await Promise.all([ adsAgent.invoke_getAdMetrics(), adsAgent.invoke_getAdScripts(), ]); if (!this.#isPolling || this.#pollSessionId !== sessionId) { return; } let needsUpdate = false; if (!metricsResponse.getError()) { this.#currentMetrics = metricsResponse.metrics; this.#processAdFrames(metricsResponse.metrics); needsUpdate = true; } if (!scriptsResponse.getError()) { this.#processAdScripts(scriptsResponse.newScripts || []); needsUpdate = true; } if (needsUpdate) { this.requestUpdate(); } } } if (this.#isPolling && this.#pollSessionId === sessionId) { this.#pollTimer = window.setTimeout(() => this.#pollMetrics(sessionId), 500); } } #processAdFrames(metrics: Protocol.Ads.AdMetrics): void { // Drop removed frames from the local cache. for (const frameId of metrics.removeAdFrames || []) { this.#adFrames.delete(frameId); this.#adIframeElementIds.delete(frameId); } // Merge partial updates into the local cache. for (const frame of metrics.updateAdFrames || []) { const frameId = frame.frameId; const existingFrame = this.#adFrames.get(frameId) || {} as Protocol.Ads.AdFrameData; // Object Spread / Protocol Undefined Behavior // To reduce the payload size, the C++ backend only sends the 'initialOrigin' field // when it changes since the last sent message for the same frame. Because // the parsed JSON frame won't have the 'initialOrigin' key if it hasn't changed, // spreading it over existingFrame won't overwrite existingFrame.initialOrigin with // undefined. const newFrame = {...existingFrame, ...frame} as Protocol.Ads.AdFrameData; this.#adFrames.set(frameId, newFrame); } // Asynchronously fetch Element IDs for newly tracked frames. // Duplicate requests are prevented by #fetchingElementIds. for (const frameId of this.#adFrames.keys()) { if (!this.#adIframeElementIds.has(frameId) && !this.#fetchingElementIds.has(frameId)) { this.#fetchingElementIds.add(frameId); void this.#fetchIframeElementId(frameId) .then(elementId => { if (this.#adFrames.has(frameId) && elementId !== undefined) { this.#adIframeElementIds.set(frameId, elementId); } }) .catch(() => {}) .finally(() => { this.#fetchingElementIds.delete(frameId); this.requestUpdate(); }); } } } // Lazily reconstructs the full script ancestry chain for a given script. // The backend guarantees that scripts across different batches are ordered // correctly (i.e., an ancestor script will always be sent in the same or an // earlier batch than its descendants). However, scripts arriving within the // same batch may be out of order. This recursive, topological approach ensures // we can resolve those in-batch ordering issues while maintaining O(N) // complexity overall via memoization in #reconstructedProvenance. #reconstructProvenance(scriptId: Protocol.Runtime.ScriptId, newScriptsMap: Map<Protocol.Runtime.ScriptId, Protocol.Ads.AdScript>): Protocol.Network.AdProvenance|null { if (this.#reconstructedProvenance.has(scriptId)) { return this.#reconstructedProvenance.get(scriptId) ?? null; } const script = newScriptsMap.get(scriptId); if (!script || !script.provenance) { return null; } let fullProvenance: Protocol.Network.AdProvenance = script.provenance; if (script.provenance.adScriptAncestry) { const immediateAncestor = script.provenance.adScriptAncestry.ancestryChain[0]; const ancestorProvenance = immediateAncestor ? this.#reconstructProvenance(immediateAncestor.scriptId, newScriptsMap) : null; if (ancestorProvenance) { const newChain = [immediateAncestor]; if (ancestorProvenance.adScriptAncestry) { newChain.push(...ancestorProvenance.adScriptAncestry.ancestryChain); } const rootScriptFilterlistRule = ancestorProvenance.filterlistRule || ancestorProvenance.adScriptAncestry?.rootScriptFilterlistRule; fullProvenance = { ...script.provenance, adScriptAncestry: { ancestryChain: newChain, ...(rootScriptFilterlistRule ? {rootScriptFilterlistRule} : {}), }, }; } } this.#reconstructedProvenance.set(scriptId, fullProvenance); return fullProvenance; } #processAdScripts(newScripts: Protocol.Ads.AdScript[]): void { const newScriptsMap = new Map(newScripts.map(s => [s.scriptId, s])); for (const script of newScripts) { this.#unresolvedScriptIds.add(script.scriptId); this.#reconstructProvenance(script.scriptId, newScriptsMap); } } async #fetchIframeElementId(frameId: Protocol.Page.FrameId): Promise<string|null|undefined> { const frame = SDK.FrameManager.FrameManager.instance().getFrame(frameId); if (!frame) { return undefined; } const deferredNode = await frame.getOwnerDeferredDOMNode(); if (deferredNode) { const node = await deferredNode.resolvePromise(); return node?.getAttribute('id') || null; } return null; } #onPrimaryPageChanged(): void { this.#currentMetrics = { viewportAdDensityByArea: 0, averageViewportAdDensityByArea: 0, viewportAdCount: 0, averageViewportAdCount: 0, totalAdCpuTime: 0, totalAdNetworkBytes: 0, updateAdFrames: [], removeAdFrames: [], }; this.#adFrames.clear(); this.#adIframeElementIds.clear(); this.#fetchingElementIds.clear(); this.#unresolvedScriptIds.clear(); this.#adScriptNodeData.length = 0; this.#urlToLinkElement.clear(); this.#reconstructedProvenance.clear(); this.requestUpdate(); } override performUpdate(): void { const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); const debuggerModel = target?.model(SDK.DebuggerModel.DebuggerModel); const adFramesArray: AdFrameNodeData[] = []; for (const [frameId, frame] of this.#adFrames) { // The table displays the resolved ID, or an <unnamed> placeholder if the // element lacks an ID attribute, or an empty string while pending. const elementIdText = this.#adIframeElementIds.has(frameId) ? (this.#adIframeElementIds.get(frameId) || i18nString(UIStrings.unnamed)) : ''; const revealFrame = (e: Event): void => { e.preventDefault(); e.stopPropagation(); const frameToReveal = SDK.FrameManager.FrameManager.instance().getFrame(frameId); if (frameToReveal) { void Common.Revealer.reveal(frameToReveal); } }; adFramesArray.push({ elementId: elementIdText, initialOrigin: frame.initialOrigin || '', cpuTime: formatCpu(frame.cpuTime), rawCpuTime: frame.cpuTime ?? -1, networkBytes: formatNetwork(frame.networkBytes), rawNetworkBytes: frame.networkBytes ?? -1, revealFrame, }); } for (const scriptId of this.#unresolvedScriptIds) { const sdkScript = debuggerModel?.scriptForId(scriptId); // Filter out inline <script> tags and anonymous eval/dynamic scripts. // We only want to display named external resources or explicitly named // scripts to reduce noise in the UI. if (!sdkScript) { continue; } this.#unresolvedScriptIds.delete(scriptId); if (sdkScript.isInlineScript() || sdkScript.isContentScript() || !sdkScript.sourceURL) { continue; } const url = sdkScript.sourceURL; // De-duplicate scripts by URL. V8 frequently generates multiple // ScriptIds for the same URL (e.g., when the same external script // is loaded into multiple iframes). if (this.#urlToLinkElement.has(url)) { continue; } this.#urlToLinkElement.set(url, Components.Linkifier.Linkifier.linkifyURL(url, {text: url})); const parsedProvenance = this.#reconstructedProvenance.get(scriptId) ?? null; this.#adScriptNodeData.push({ url, parsedProvenance, scriptId, }); } const viewInput: ViewInput = { metrics: this.#currentMetrics, adFrames: adFramesArray, adScripts: this.#adScriptNodeData, target: target || null, getLinkElement: (url: string) => this.#urlToLinkElement.get(url) as HTMLElement, }; this.#view(viewInput, undefined, this.contentElement); } }