| // Copyright 2024 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/optimization_guide/content/browser/page_content_proto_provider.h" |
| |
| #include "base/check.h" |
| #include "base/functional/concurrent_closures.h" |
| #include "base/i18n/char_iterator.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/task/thread_pool.h" |
| #include "base/timer/elapsed_timer.h" |
| #include "components/optimization_guide/content/browser/media_transcript_provider.h" |
| #include "components/optimization_guide/content/browser/page_content_proto_util.h" |
| #include "components/optimization_guide/core/optimization_guide_features.h" |
| #include "content/public/browser/media_session.h" |
| #include "content/public/browser/render_frame_host.h" |
| #include "content/public/browser/web_contents.h" |
| #include "mojo/public/cpp/bindings/callback_helpers.h" |
| #include "mojo/public/cpp/bindings/remote.h" |
| #include "services/metrics/public/cpp/metrics_utils.h" |
| #include "services/metrics/public/cpp/ukm_builders.h" |
| #include "services/metrics/public/cpp/ukm_recorder.h" |
| #include "services/service_manager/public/cpp/interface_provider.h" |
| |
| namespace optimization_guide { |
| |
| namespace { |
| |
| // The maximum limit for computing the metrics. |
| constexpr size_t kMaxWordLimit = 100000; |
| constexpr size_t kMaxNodeLimit = 100000; |
| |
| struct ContentNodeMetrics { |
| size_t word_count = 0; |
| size_t node_count = 0; |
| }; |
| |
| void ApplyOptionsOverridesForWebContents( |
| content::WebContents* web_contents, |
| blink::mojom::AIPageContentOptions& options) { |
| // TODO(crbug.com/389735650): Renderers with no visible Documents will |
| // throttle idle tasks after a duration of 10 seconds. In order to avoid the |
| // page content request from getting starved, force critical path for hidden |
| // WebContents. |
| if (web_contents->GetVisibility() != content::Visibility::VISIBLE) { |
| options.on_critical_path = true; |
| } |
| |
| if (base::FeatureList::IsEnabled( |
| features::kAnnotatedPageContentWithActionableElements)) { |
| options.mode = blink::mojom::AIPageContentMode::kActionableElements; |
| } |
| } |
| |
| blink::mojom::AIPageContentOptionsPtr ApplyOptionsOverridesForSubframe( |
| const content::RenderProcessHost* main_process, |
| const content::RenderProcessHost* subframe_process, |
| const blink::mojom::AIPageContentOptions& input) { |
| if (main_process == subframe_process) { |
| return input.Clone(); |
| } |
| |
| // TODO(crbug.com/389737599): There's a bug with scheduling idle tasks in an |
| // OOPIF with site isolation if there are no other main frames in the process. |
| // See crbug.com/40785325. |
| auto new_options = blink::mojom::AIPageContentOptions::New(input); |
| new_options->on_critical_path = true; |
| return new_options; |
| } |
| |
| // Validate that the media session has all the required data before proceeding |
| // to create media data. |
| bool ValidateMediaSession( |
| const media_session::mojom::MediaSessionInfoPtr& media_session_info, |
| const std::optional<media_session::MediaPosition>& media_position) { |
| return media_session_info && media_session_info->audio_video_states && |
| !media_session_info->audio_video_states->empty() && media_position && |
| !media_position->duration().is_zero(); |
| } |
| |
| // Find the media data from the web contents for the given render frame host if |
| // the media data exists, otherwise return nullopt. |
| std::optional<optimization_guide::proto::MediaData> ComputeMediaData( |
| content::RenderFrameHost* render_frame_host) { |
| CHECK(render_frame_host); |
| if (!base::FeatureList::IsEnabled( |
| features::kAnnotatedPageContentWithMediaData)) { |
| return std::nullopt; |
| } |
| |
| auto* web_contents = |
| content::WebContents::FromRenderFrameHost(render_frame_host); |
| if (!web_contents) { |
| return std::nullopt; |
| } |
| |
| // Populate the transcripts field in media data if the transcripts exist for |
| // this render frame host. The transcripts could have been generated for |
| // previous media sessions in this render frame host, or are being generated |
| // for the currently active media session. The transcripts will be cleared |
| // when the render frame host changes. |
| std::optional<optimization_guide::proto::MediaData> media_data; |
| if (auto* media_transcript_provider = |
| MediaTranscriptProvider::GetFor(web_contents)) { |
| auto transcripts = |
| media_transcript_provider->GetTranscriptsForFrame(render_frame_host); |
| if (!transcripts.empty()) { |
| media_data.emplace(); |
| media_data->mutable_transcripts()->Add(transcripts.begin(), |
| transcripts.end()); |
| } |
| } |
| |
| // If there is an active media session in the web page, we may generate other |
| // fields in media data if the given render frame host controls the media |
| // session. |
| auto* media_session = content::MediaSession::GetIfExists(web_contents); |
| if (!media_session || |
| (render_frame_host != media_session->GetRoutedFrame())) { |
| return media_data; |
| } |
| |
| // Validate the media data for other fields before populating them. |
| auto media_session_info = media_session->GetMediaSessionInfoSync(); |
| auto media_position = media_session->GetMediaSessionPosition(); |
| if (!ValidateMediaSession(media_session_info, media_position)) { |
| return media_data; |
| } |
| |
| // Initialize the media data if there are no transcripts so that it was not |
| // initialized before. |
| if (!media_data) { |
| media_data.emplace(); |
| } |
| |
| media_data->set_is_playing( |
| media_session_info->playback_state == |
| media_session::mojom::MediaPlaybackState::kPlaying); |
| media_data->set_duration_milliseconds( |
| media_position->duration().InMilliseconds()); |
| media_data->set_current_position_milliseconds( |
| media_position->GetPosition().InMilliseconds()); |
| |
| // Find the media data type via the audio video states in the media session |
| // info. If there are multiple media in the frame which is rare, select the |
| // first media for simplicity. |
| auto& first_state = media_session_info->audio_video_states->at(0); |
| switch (first_state) { |
| case media_session::mojom::MediaAudioVideoState::kAudioOnly: |
| media_data->set_media_data_type( |
| optimization_guide::proto::MediaDataType::MEDIA_DATA_TYPE_AUDIO); |
| break; |
| case media_session::mojom::MediaAudioVideoState::kAudioVideo: |
| case media_session::mojom::MediaAudioVideoState::kVideoOnly: |
| media_data->set_media_data_type( |
| optimization_guide::proto::MediaDataType::MEDIA_DATA_TYPE_VIDEO); |
| break; |
| case media_session::mojom::MediaAudioVideoState::kDeprecatedUnknown: |
| NOTREACHED(); |
| } |
| |
| // Set the media metadata. |
| const media_session::MediaMetadata& media_metadata = |
| media_session->GetMediaSessionMetadata(); |
| media_data->set_title(base::UTF16ToUTF8(media_metadata.title)); |
| media_data->set_artist(base::UTF16ToUTF8(media_metadata.artist)); |
| media_data->set_album(base::UTF16ToUTF8(media_metadata.album)); |
| |
| return media_data; |
| } |
| |
| std::optional<optimization_guide::RenderFrameInfo> GetRenderFrameInfo( |
| int child_process_id, |
| blink::FrameToken frame_token) { |
| content::RenderFrameHost* render_frame_host = nullptr; |
| |
| if (frame_token.Is<blink::RemoteFrameToken>()) { |
| render_frame_host = content::RenderFrameHost::FromPlaceholderToken( |
| child_process_id, frame_token.GetAs<blink::RemoteFrameToken>()); |
| } else { |
| render_frame_host = content::RenderFrameHost::FromFrameToken( |
| content::GlobalRenderFrameHostToken( |
| child_process_id, frame_token.GetAs<blink::LocalFrameToken>())); |
| } |
| |
| if (!render_frame_host) { |
| return std::nullopt; |
| } |
| |
| auto serialized_server_token = |
| DocumentIdentifierUserData::GetDocumentIdentifier( |
| render_frame_host->GetGlobalFrameToken()); |
| CHECK(serialized_server_token.has_value()); |
| |
| optimization_guide::RenderFrameInfo render_frame_info; |
| render_frame_info.serialized_server_token = serialized_server_token.value(); |
| render_frame_info.global_frame_token = |
| render_frame_host->GetGlobalFrameToken(); |
| // We use the origin instead of last committed URL here to ensure the security |
| // origin for the iframe's content is accurately tracked. |
| // For example: |
| // 1. data URLs use an opaque origin |
| // 2. about:blank inherits its origin from the initiator while the URL doesn't |
| // convey that. |
| render_frame_info.source_origin = render_frame_host->GetLastCommittedOrigin(); |
| render_frame_info.url = render_frame_host->GetLastCommittedURL(); |
| render_frame_info.media_data = ComputeMediaData(render_frame_host); |
| return render_frame_info; |
| } |
| |
| // Computes the metrics in one pass. |
| void ComputeContentNodeMetrics( |
| const optimization_guide::proto::ContentNode& content_node, |
| ContentNodeMetrics* metrics) { |
| bool is_previous_char_whitespace = true; |
| for (base::i18n::UTF8CharIterator iter( |
| content_node.content_attributes().text_data().text_content()); |
| metrics->word_count < kMaxWordLimit && !iter.end(); iter.Advance()) { |
| bool is_current_char_whitespace = base::IsUnicodeWhitespace(iter.get()); |
| if (is_previous_char_whitespace && !is_current_char_whitespace) { |
| // Count the start of the word. |
| ++metrics->word_count; |
| } |
| is_previous_char_whitespace = is_current_char_whitespace; |
| } |
| metrics->node_count += 1; |
| |
| for (const auto& child : content_node.children_nodes()) { |
| ComputeContentNodeMetrics(child, metrics); |
| if (metrics->node_count > kMaxNodeLimit) { |
| break; |
| } |
| } |
| } |
| |
| void RecordPageContentExtractionMetrics( |
| base::TimeDelta total_latency, |
| ukm::SourceId source_id, |
| blink::mojom::AIPageContentMode mode, |
| bool on_critical_path, |
| optimization_guide::proto::AnnotatedPageContent proto) { |
| ContentNodeMetrics metrics; |
| auto total_size = proto.ByteSizeLong(); |
| base::ElapsedTimer elapsed; |
| |
| ComputeContentNodeMetrics(proto.root_node(), &metrics); |
| UMA_HISTOGRAM_TIMES("OptimizationGuide.AIPageContent.TotalLatency", |
| total_latency); |
| if (mode == blink::mojom::AIPageContentMode::kDefault) { |
| if (on_critical_path) { |
| UMA_HISTOGRAM_TIMES( |
| "OptimizationGuide.AIPageContent.TotalLatency.Default.CriticalPath", |
| total_latency); |
| } else { |
| UMA_HISTOGRAM_TIMES( |
| "OptimizationGuide.AIPageContent.TotalLatency.Default." |
| "NotCriticalPath", |
| total_latency); |
| } |
| } else if (mode == blink::mojom::AIPageContentMode::kActionableElements) { |
| if (on_critical_path) { |
| UMA_HISTOGRAM_TIMES( |
| "OptimizationGuide.AIPageContent.TotalLatency.ActionableElements." |
| "CriticalPath", |
| total_latency); |
| } else { |
| UMA_HISTOGRAM_TIMES( |
| "OptimizationGuide.AIPageContent.TotalLatency.ActionableElements." |
| "NotCriticalPath", |
| total_latency); |
| } |
| } |
| // 10KB bucket up to 5MB. |
| // TODO(crbug.com/392115749): Use provided metrics when available. |
| if (mode == blink::mojom::AIPageContentMode::kDefault) { |
| UMA_HISTOGRAM_CUSTOM_COUNTS( |
| "OptimizationGuide.AnnotatedPageContent.TotalSize2.Default", |
| total_size / 1024, 10, 5000, 50); |
| UMA_HISTOGRAM_CUSTOM_COUNTS( |
| "OptimizationGuide.AnnotatedPageContent.TotalNodeCount.Default", |
| metrics.node_count, 1, kMaxNodeLimit, 50); |
| } else if (mode == blink::mojom::AIPageContentMode::kActionableElements) { |
| UMA_HISTOGRAM_CUSTOM_COUNTS( |
| "OptimizationGuide.AnnotatedPageContent.TotalSize2.ActionableElements", |
| total_size / 1024, 10, 5000, 50); |
| UMA_HISTOGRAM_CUSTOM_COUNTS( |
| "OptimizationGuide.AnnotatedPageContent.TotalNodeCount." |
| "ActionableElements", |
| metrics.node_count, 1, kMaxNodeLimit, 50); |
| } |
| UMA_HISTOGRAM_CUSTOM_COUNTS( |
| "OptimizationGuide.AnnotatedPageContent.TotalWordCount", |
| metrics.word_count, 1, kMaxWordLimit, 50); |
| |
| ukm::builders::OptimizationGuide_AnnotatedPageContent(source_id) |
| .SetMode(static_cast<int64_t>(mode)) |
| .SetOnCriticalPath(on_critical_path) |
| .SetTotalSize(ukm::GetExponentialBucketMinForBytes(total_size)) |
| .SetExtractionLatency(ukm::GetExponentialBucketMinForUserTiming( |
| total_latency.InMilliseconds())) |
| .SetWordsCount(ukm::GetExponentialBucketMinForBytes(metrics.word_count)) |
| .SetNodeCount(ukm::GetExponentialBucketMinForBytes(metrics.node_count)) |
| .Record(ukm::UkmRecorder::Get()); |
| |
| UMA_HISTOGRAM_CUSTOM_MICROSECONDS_TIMES( |
| "OptimizationGuide.AnnotatedPageContent.ComputeMetricsLatency", |
| elapsed.Elapsed(), base::Microseconds(1), base::Milliseconds(5), 50); |
| } |
| |
| // Converts gfx::Size to optimization_guide::proto::ViewportGeometry. |
| void ConvertViewportGeometry( |
| const gfx::Size& viewport, |
| optimization_guide::proto::BoundingRect* viewport_geometry) { |
| viewport_geometry->set_x(0); |
| viewport_geometry->set_y(0); |
| viewport_geometry->set_width(viewport.width()); |
| viewport_geometry->set_height(viewport.height()); |
| } |
| |
| void OnGotAIPageContentForAllFrames( |
| blink::mojom::AIPageContentOptionsPtr main_frame_options, |
| base::ElapsedTimer elapsed_timer, |
| content::GlobalRenderFrameHostToken main_frame_token, |
| ukm::SourceId source_id, |
| const gfx::Size& main_frame_viewport, |
| std::unique_ptr<optimization_guide::AIPageContentMap> page_content_map, |
| OnAIPageContentDone done_callback) { |
| optimization_guide::AIPageContentResult page_content; |
| optimization_guide::FrameTokenSet frame_token_set; |
| auto mode = main_frame_options->mode; |
| bool on_critical_path = main_frame_options->on_critical_path; |
| |
| if (auto result = optimization_guide::ConvertAIPageContentToProto( |
| std::move(main_frame_options), main_frame_token, *page_content_map, |
| base::BindRepeating(&GetRenderFrameInfo), frame_token_set, |
| page_content); |
| !result.has_value()) { |
| std::move(done_callback).Run(std::nullopt); |
| return; |
| } |
| |
| ConvertViewportGeometry(main_frame_viewport, |
| page_content.proto.mutable_viewport_geometry()); |
| |
| // Get all the document identifiers for the frames that were seen. |
| for (const auto& frame_token : frame_token_set) { |
| content::RenderFrameHost* render_frame_host = |
| content::RenderFrameHost::FromFrameToken(frame_token); |
| CHECK(render_frame_host); |
| auto token = DocumentIdentifierUserData::GetDocumentIdentifier(frame_token); |
| CHECK(token.has_value()); |
| page_content.document_identifiers[token.value()] = |
| render_frame_host->GetWeakDocumentPtr(); |
| } |
| |
| base::ThreadPool::PostTask(FROM_HERE, {base::TaskPriority::BEST_EFFORT}, |
| base::BindOnce( |
| RecordPageContentExtractionMetrics, |
| elapsed_timer.Elapsed(), source_id, mode, on_critical_path, |
| page_content.proto)); |
| std::move(done_callback).Run(std::move(page_content)); |
| } |
| |
| void OnGotAIPageContentForFrame( |
| content::GlobalRenderFrameHostToken frame_token, |
| mojo::Remote<blink::mojom::AIPageContentAgent> remote_interface, |
| optimization_guide::AIPageContentMap* page_content_map, |
| base::OnceClosure continue_callback, |
| blink::mojom::AIPageContentPtr result) { |
| CHECK(page_content_map->find(frame_token) == page_content_map->end()); |
| |
| if (result) { |
| (*page_content_map)[frame_token] = std::move(result); |
| } |
| std::move(continue_callback).Run(); |
| } |
| |
| } // namespace |
| |
| AIPageContentResult::AIPageContentResult() { |
| metadata = blink::mojom::PageMetadata::New(); |
| } |
| AIPageContentResult::~AIPageContentResult() = default; |
| AIPageContentResult::AIPageContentResult(AIPageContentResult&& other) = default; |
| AIPageContentResult& AIPageContentResult::operator=( |
| AIPageContentResult&& other) = default; |
| |
| blink::mojom::AIPageContentOptionsPtr DefaultAIPageContentOptions( |
| bool on_critical_path) { |
| auto options = blink::mojom::AIPageContentOptions::New(); |
| options->mode = blink::mojom::AIPageContentMode::kDefault; |
| options->on_critical_path = on_critical_path; |
| return options; |
| } |
| |
| blink::mojom::AIPageContentOptionsPtr ActionableAIPageContentOptions( |
| bool on_critical_path) { |
| auto options = blink::mojom::AIPageContentOptions::New(); |
| options->mode = blink::mojom::AIPageContentMode::kActionableElements; |
| options->on_critical_path = on_critical_path; |
| return options; |
| } |
| |
| void GetAIPageContent(content::WebContents* web_contents, |
| blink::mojom::AIPageContentOptionsPtr options, |
| OnAIPageContentDone done_callback) { |
| DCHECK(web_contents); |
| DCHECK(web_contents->GetPrimaryMainFrame()); |
| |
| if (!web_contents->GetPrimaryMainFrame()->IsRenderFrameLive()) { |
| std::move(done_callback).Run(std::nullopt); |
| return; |
| } |
| |
| ApplyOptionsOverridesForWebContents(web_contents, *options); |
| auto page_content_map = |
| std::make_unique<optimization_guide::AIPageContentMap>(); |
| base::ConcurrentClosures concurrent; |
| const auto* main_frame_rph = |
| web_contents->GetPrimaryMainFrame()->GetProcess(); |
| |
| web_contents->GetPrimaryMainFrame()->ForEachRenderFrameHost( |
| [&](content::RenderFrameHost* rfh) { |
| if (!rfh->IsRenderFrameLive()) { |
| return; |
| } |
| |
| auto* parent_frame = rfh->GetParentOrOuterDocument(); |
| |
| // Skip dispatching IPCs for non-local root frames. The local root |
| // provides data for itself and all child local frames. |
| const bool is_local_root = |
| !parent_frame || |
| parent_frame->GetRenderWidgetHost() != rfh->GetRenderWidgetHost(); |
| if (!is_local_root) { |
| return; |
| } |
| |
| const bool is_subframe = parent_frame != nullptr; |
| auto options_to_use = |
| is_subframe ? ApplyOptionsOverridesForSubframe( |
| main_frame_rph, rfh->GetProcess(), *options) |
| : options.Clone(); |
| |
| mojo::Remote<blink::mojom::AIPageContentAgent> agent; |
| rfh->GetRemoteInterfaces()->GetInterface( |
| agent.BindNewPipeAndPassReceiver()); |
| auto* agent_ptr = agent.get(); |
| agent_ptr->GetAIPageContent( |
| std::move(options_to_use), |
| mojo::WrapCallbackWithDefaultInvokeIfNotRun( |
| base::BindOnce(&OnGotAIPageContentForFrame, |
| rfh->GetGlobalFrameToken(), std::move(agent), |
| page_content_map.get(), |
| concurrent.CreateClosure()), |
| nullptr)); |
| }); |
| |
| std::move(concurrent) |
| .Done(base::BindOnce( |
| &OnGotAIPageContentForAllFrames, options.Clone(), |
| base::ElapsedTimer(), |
| web_contents->GetPrimaryMainFrame()->GetGlobalFrameToken(), |
| web_contents->GetPrimaryMainFrame()->GetPageUkmSourceId(), |
| web_contents->GetSize(), std::move(page_content_map), |
| std::move(done_callback))); |
| } |
| |
| // Allows for a DocumentIdentifier to be reused across calls to convert |
| std::optional<std::string> DocumentIdentifierUserData::GetDocumentIdentifier( |
| content::GlobalRenderFrameHostToken token) { |
| content::RenderFrameHost* render_frame_host = |
| content::RenderFrameHost::FromFrameToken(token); |
| if (!render_frame_host) { |
| return std::nullopt; |
| } |
| return DocumentIdentifierUserData::GetOrCreateForCurrentDocument( |
| render_frame_host) |
| ->serialized_token(); |
| } |
| |
| DOCUMENT_USER_DATA_KEY_IMPL(DocumentIdentifierUserData); |
| |
| DocumentIdentifierUserData::DocumentIdentifierUserData( |
| content::RenderFrameHost* rfh) |
| : DocumentUserData<DocumentIdentifierUserData>(rfh), |
| token_(base::UnguessableToken::Create()), |
| serialized_token_(token_.ToString()) {} |
| |
| DocumentIdentifierUserData::~DocumentIdentifierUserData() = default; |
| |
| } // namespace optimization_guide |