blob: 6c4483c2463086bcbb23ae11d7561789aec37909 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "enterprise_search_aggregator_provider.h"
#include <algorithm>
#include <ctime>
#include <functional>
#include <iomanip>
#include <iterator>
#include <memory>
#include <optional>
#include <ranges>
#include <set>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "base/containers/contains.h"
#include "base/containers/fixed_flat_map.h"
#include "base/i18n/case_conversion.h"
#include "base/json/json_reader.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "base/types/expected.h"
#include "base/values.h"
#include "components/omnibox/browser/autocomplete_enums.h"
#include "components/omnibox/browser/autocomplete_input.h"
#include "components/omnibox/browser/autocomplete_match.h"
#include "components/omnibox/browser/autocomplete_match_classification.h"
#include "components/omnibox/browser/autocomplete_provider.h"
#include "components/omnibox/browser/autocomplete_provider_client.h"
#include "components/omnibox/browser/autocomplete_provider_listener.h"
#include "components/omnibox/browser/remote_suggestions_service.h"
#include "components/omnibox/browser/search_suggestion_parser.h"
#include "components/omnibox/common/omnibox_feature_configs.h"
#include "components/omnibox/common/string_cleaning.h"
#include "components/search/search.h"
#include "components/search_engines/template_url.h"
#include "components/search_engines/template_url_data.h"
#include "components/search_engines/template_url_service.h"
#include "components/strings/grit/components_strings.h"
#include "components/url_formatter/url_formatter.h"
#include "services/data_decoder/public/cpp/data_decoder.h"
#include "services/network/public/cpp/simple_url_loader.h"
#include "third_party/re2/src/re2/re2.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/page_transition_types.h"
#include "url/gurl.h"
namespace {
// Whether to make multiple requests to the backend.
bool kMultipleRequests() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.multiple_requests;
}
// Limit the number matches created for each type, not total, as a performance
// guard.
size_t kMaxMatchesCreatedPerType() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_max_matches_created_per_type;
}
// Limit the number of matches shown for each type, not total. Needed to prevent
// inputs like 'joe' or 'doc' from flooding the results with `PEOPLE` or
// `CONTENT` suggestions. More matches may be created in order to ensure the
// best matches are shown.
size_t kMaxScopedMatchesShownPerType() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_max_scoped_matches_shown_per_type;
}
size_t kMaxUnscopedMatchesShownPerType() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_max_unscoped_matches_shown_per_type;
}
// Score matches based on text similarity of the input and match fields.
// - Strong matches are input words at least 3 chars long that match the
// suggestion content or description.
// - For PEOPLE suggestions, input words of 1 or 2 chars are strong matches if
// they fully match (rather than prefix match) the suggestion content or
// description. E.g. "jo" will be a strong match for "Jo Jacob", but "ja"
// won't.
// - Weak matches are input words shorter than 3 chars or that match elsewhere
// in the match fields.
// TODO(manukh): For consistency, rename "Text" to "Word" when finch params are
// expired.
size_t kMinCharForStrongTextMatch() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_min_char_for_strong_text_match;
}
// If a) every input word is a strong match, and b) there are at least 2 such
// matches, score matches 1000.
size_t kMinWordsForFullTextMatchBoost() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_min_words_for_full_text_match_boost;
}
int kFullTextMatchScore() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_full_text_match_score;
}
// Otherwise, score using a weighted sum of the # of strong and weak matches.
int kScorePerStrongTextMatch() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_score_per_strong_text_match;
}
int kScorePerWeakTextMatch() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_score_per_weak_text_match;
}
int kMaxTextScore() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_max_text_score;
}
// Shift people relevances whose email username was exactly matched by an input
// term. Some people-seeking inputs will have words intended to match email
// usernames and scoring these 400 wouldn't reliably allow them to make it to
// the final results.
int kPeopleEmailMatchScoreBoost() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_people_email_match_score_boost;
}
// Shift people relevances higher than calculated with the above constants. Most
// people-seeking inputs will have 2 words (firstname, lastname) and scoring
// these 800 wouldn't reliably allow them to make it to the final results.
int kPeopleScoreBoost() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_people_score_boost;
}
// When suggestions equally match the input, prefer showing content over query
// suggestions. This wont affect ranking due to grouping, only which suggestions
// are shown. This won't affect people suggestions unless `kPeopleScoreBoost` is
// 0.
bool kPreferContentsOverQueries() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_prefer_contents_over_queries;
}
// Always show at least 2 (unscoped) or 8 (scoped) suggestions if available.
// Only show more if they're scored at least 500; i.e. had at least 1 strong and
// 1 weak match.
size_t kScopedMaxLowQualityMatches() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_scoped_max_low_quality_matches;
}
size_t kUnscopedMaxLowQualityMatches() {
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_unscoped_max_low_quality_matches;
}
int kLowQualityThreshold() {
// When this is converted back to a `constexpr`, it should be relative to
// `scoring_score_per_strong_text_match` & `scoring_score_per_weak_text_match`
// instead of an independent int.
return omnibox_feature_configs::SearchAggregatorProvider::Get()
.scoring_low_quality_threshold;
}
// Helper for reading possibly null paths from `base::Value::Dict`.
std::string ptr_to_string(const std::string* ptr) {
return ptr ? *ptr : "";
}
// A mapping from `mime_type` to the human readable `file_type_description` for
// selected MIME types.
// Mappings documentation:
// https://developers.google.com/drive/api/guides/mime-types
// https://developers.google.com/drive/api/guides/ref-export-formats
const auto kMimeTypeMapping = base::MakeFixedFlatMap<std::string_view, int>({
{"application/json", IDS_CONTENT_SUGGESTION_DESCRIPTION_JSON},
{"application/rtf", IDS_CONTENT_SUGGESTION_DESCRIPTION_RICH_TEXT_FORMAT},
{"application/pdf", IDS_CONTENT_SUGGESTION_DESCRIPTION_PDF},
{"application/vnd.google-apps.document",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_DOCS},
{"application/vnd.google-apps.drawing",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_DRAWINGS},
{"application/vnd.google-apps.folder",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_DRIVE_FOLDER},
{"application/vnd.google-apps.form",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_FORMS},
{"application/vnd.google-apps.jam",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_JAMBOARD},
{"application/vnd.google-apps.photo",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_PHOTOS},
{"application/vnd.google-apps.presentation",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_SLIDES},
{"application/vnd.google-apps.script",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_APPS_SCRIPT},
{"application/vnd.google-apps.site",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_SITES},
{"application/vnd.google-apps.spreadsheet",
IDS_CONTENT_SUGGESTION_DESCRIPTION_GOOGLE_SHEETS},
{"application/"
"vnd.openxmlformats-officedocument.presentationml.presentation",
IDS_CONTENT_SUGGESTION_DESCRIPTION_MS_POWERPOINT},
{"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
IDS_CONTENT_SUGGESTION_DESCRIPTION_MS_EXCEL},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
IDS_CONTENT_SUGGESTION_DESCRIPTION_MS_WORD},
{"application/vnd.oasis.opendocument.presentation",
IDS_CONTENT_SUGGESTION_DESCRIPTION_OPEN_DOCUMENT_PRESENTATION},
{"application/vnd.oasis.opendocument.spreadsheet",
IDS_CONTENT_SUGGESTION_DESCRIPTION_OPEN_DOCUMENT_SPREADSHEET},
{"application/vnd.oasis.opendocument.text",
IDS_CONTENT_SUGGESTION_DESCRIPTION_OPEN_DOCUMENT_TEXT},
{"application/zip", IDS_CONTENT_SUGGESTION_DESCRIPTION_ZIP_FILE},
{"image/jpeg", IDS_CONTENT_SUGGESTION_DESCRIPTION_IMAGE_JPEG},
{"image/png", IDS_CONTENT_SUGGESTION_DESCRIPTION_IMAGE_PNG},
{"image/svg+xml", IDS_CONTENT_SUGGESTION_DESCRIPTION_IMAGE_SVG},
{"text/csv", IDS_CONTENT_SUGGESTION_DESCRIPTION_COMMA_SEPARATED_VALUES},
{"text/markdown", IDS_CONTENT_SUGGESTION_DESCRIPTION_MARKDOWN},
{"text/plain", IDS_CONTENT_SUGGESTION_DESCRIPTION_PLAIN_TEXT},
{"video/mp4", IDS_CONTENT_SUGGESTION_DESCRIPTION_VIDEO_MP4},
{"video/quicktime", IDS_CONTENT_SUGGESTION_DESCRIPTION_VIDEO_QUICKTIME},
{"video/webm", IDS_CONTENT_SUGGESTION_DESCRIPTION_VIDEO_WEBM},
});
// A mapping from `source_type` to the human readable
// `content_type_description`.
const auto kSourceTypeMapping = base::MakeFixedFlatMap<std::string_view, int>({
{"buganizer", IDS_CONTENT_SUGGESTION_DESCRIPTION_BUGANIZER},
{"jira", IDS_CONTENT_SUGGESTION_DESCRIPTION_JIRA},
{"salesforce", IDS_CONTENT_SUGGESTION_DESCRIPTION_SALESFORCE},
{"slack", IDS_CONTENT_SUGGESTION_DESCRIPTION_SLACK},
});
// Helper for converting `mime_type` and `source_type` into a human readable
// string. Prioritizes `mime_type` over `source_type`.
std::u16string ContentTypeToDescription(const std::string_view& mime_type,
const std::string_view& source_type) {
const auto mimeTypeIter = kMimeTypeMapping.find(mime_type);
if (mimeTypeIter != kMimeTypeMapping.end()) {
return l10n_util::GetStringUTF16(mimeTypeIter->second);
}
const auto sourceTypeIter = kSourceTypeMapping.find(source_type);
return sourceTypeIter != kSourceTypeMapping.end()
? l10n_util::GetStringUTF16(sourceTypeIter->second)
: std::u16string();
}
// Helper for converting unix timestamp `time` into an abbreviated date.
// For time within the current day, return the time of day. (Ex. '12:45 PM')
// For time within the current year, return the abbreviated date. (Ex. 'Jan 02')
// Otherwise, return the full date. (Ex. '10/7/24')
const std::u16string UpdateTimeToString(std::optional<int> time) {
if (!time) {
return u"";
}
std::time_t unix_time = static_cast<std::time_t>(time.value());
std::tm* local_time = std::localtime(&unix_time);
if (!local_time) {
return u"";
}
// Get current time to check if `unix_time` is in the current day or year.
base::Time check_time = base::Time::FromTimeT(unix_time);
base::Time now = base::Time::Now();
return AutocompleteProvider::LocalizedLastModifiedString(now, check_time);
}
// Helper for getting the correct `TemplateURL` based on the input.
const TemplateURL* AdjustTemplateURL(AutocompleteInput* input,
TemplateURLService* turl_service) {
DCHECK(turl_service);
return input->InKeywordMode()
? AutocompleteInput::GetSubstitutingTemplateURLForInput(
turl_service, input)
: turl_service->GetEnterpriseSearchAggregatorEngine();
}
EnterpriseSearchAggregatorProvider::RelevanceData GetServerRelevanceData(
const base::Value::Dict& result) {
return {static_cast<int>(result.FindDouble("score").value_or(0) * 1000), 0, 0,
"server"};
}
// Helpers to convert vector of strings to sets of words.
std::set<std::u16string> GetWords(std::vector<std::u16string> strings) {
std::set<std::u16string> words = {};
for (const auto& string : strings) {
auto string_words = String16VectorFromString16(
omnibox::CleanUpTitleForMatching(string), nullptr);
std::move(string_words.begin(), string_words.end(),
std::inserter(words, words.begin()));
}
return words;
}
std::set<std::u16string> GetWords(std::vector<std::string> strings) {
std::vector<std::u16string> u16strings;
std::ranges::transform(
strings, std::back_inserter(u16strings),
[](const auto& string) { return base::UTF8ToUTF16(string); });
return GetWords(u16strings);
}
// Helper for getting a list of lowercase email usernames from the result
// dictionary.
const std::vector<std::u16string> GetEmailUsernames(
const base::Value::Dict& result) {
std::vector<std::u16string> usernames;
const base::Value::List* emails =
result.FindListByDottedPath("document.derivedStructData.emails");
if (!emails) {
return usernames;
}
for (const auto& email : *emails) {
const std::string* email_value = email.GetDict().FindString("value");
if (!email_value) {
continue;
}
size_t at_pos = email_value->find('@');
if (at_pos != std::string::npos) {
usernames.push_back(base::i18n::ToLower(
base::UTF8ToUTF16(email_value->substr(0, at_pos))));
}
}
return usernames;
}
// Whether `word` matches any of `potential_match_words`.
enum class WordMatchType {
NONE = 0,
PREFIX, // E.g. 'goo' prefixes 'goo' and 'google'.
EXACT, // E.g. 'goo' exactly matches 'goo' but not 'google'.
};
WordMatchType GetWordMatchType(std::u16string word,
std::set<std::u16string> potential_match_words) {
auto it = potential_match_words.lower_bound(word);
if (it == potential_match_words.end()) {
return WordMatchType::NONE;
}
if (word == *it) {
return WordMatchType::EXACT;
}
if (base::StartsWith(*it, word, base::CompareCase::SENSITIVE)) {
return WordMatchType::PREFIX;
}
return WordMatchType::NONE;
}
// Returns 0 if the match should be filtered out.
EnterpriseSearchAggregatorProvider::RelevanceData CalculateRelevanceData(
std::set<std::u16string> input_words,
bool in_keyword_mode,
AutocompleteMatch::EnterpriseSearchAggregatorType suggestion_type,
const std::vector<std::string> strong_scoring_fields,
const std::vector<std::string> weak_scoring_fields,
const std::vector<std::u16string> email_usernames) {
// Split match fields into words.
std::set<std::u16string> strong_scoring_words =
GetWords(strong_scoring_fields);
std::set<std::u16string> weak_scoring_words = GetWords(weak_scoring_fields);
// Do not use `GetWords()` for email usernames as it may split the username by
// special symbols leading to false positives in "exact" matching.
std::set<std::u16string> email_usernames_words;
std::ranges::transform(
email_usernames,
std::inserter(email_usernames_words, email_usernames_words.end()),
[](const std::u16string& email_username) { return email_username; });
// Compute text similarity of the input and match fields. See comment for
// `kMinCharForStrongTextMatch`.
size_t strong_word_matches = 0;
size_t weak_word_matches = 0;
bool has_email_match = false;
for (const auto& input_word : input_words) {
WordMatchType strong_match_type =
GetWordMatchType(input_word, strong_scoring_words);
if (strong_match_type == WordMatchType::EXACT &&
suggestion_type ==
AutocompleteMatch::EnterpriseSearchAggregatorType::PEOPLE) {
strong_word_matches++;
} else if (strong_match_type != WordMatchType::NONE) {
if (input_word.size() >= kMinCharForStrongTextMatch()) {
strong_word_matches++;
} else {
weak_word_matches++;
}
} else if (GetWordMatchType(input_word, weak_scoring_words) !=
WordMatchType::NONE) {
weak_word_matches++;
}
// Check if the input has exact match with the email username fields for
// people suggestions.
if (!has_email_match &&
suggestion_type ==
AutocompleteMatch::EnterpriseSearchAggregatorType::PEOPLE &&
GetWordMatchType(input_word, email_usernames_words) ==
WordMatchType::EXACT) {
has_email_match = true;
}
}
// Skip if there aren't at least 1 strong match or 2 weak matches.
if (!in_keyword_mode && strong_word_matches == 0 && weak_word_matches < 2) {
return {0, strong_word_matches, weak_word_matches,
"local, less than 1 strong or 2 weak word matches"};
}
// Skip when less than half the input words had matches. The backend
// prioritizes high recall, whereas most omnibox suggestions require every
// input word to match.
if ((strong_word_matches + weak_word_matches) * 2 < input_words.size()) {
return {0, strong_word_matches, weak_word_matches,
"local, less than half the input words matched"};
}
// Compute `relevance` using text similarity. See comments for
// `kMinWordsForFullTextMatchBoost` & `kScorePerStrongTextMatch`.
CHECK_LE(kMaxTextScore(), kFullTextMatchScore());
int relevance = 0;
if (strong_word_matches == input_words.size() &&
strong_word_matches >= kMinWordsForFullTextMatchBoost()) {
relevance = kFullTextMatchScore();
} else {
relevance = std::min(
static_cast<int>(strong_word_matches) * kScorePerStrongTextMatch() +
static_cast<int>(weak_word_matches) * kScorePerWeakTextMatch(),
kMaxTextScore());
}
// People suggestions must match every input word. Otherwise, they feel bad;
// e.g. 'omnibox c' shouldn't suggest 'Charles Aznavour'. This doesn't apply
// to `QUERY` and `CONTENT` types because those might have fuzzy matches or
// matches within their contents.
if (suggestion_type ==
AutocompleteMatch::EnterpriseSearchAggregatorType::PEOPLE) {
if (strong_word_matches + weak_word_matches < input_words.size()) {
return {0, strong_word_matches, weak_word_matches,
"local, unmatched input word for PEOPLE type"};
} else {
// See comment for `kPeopleEmailMatchScoreBoost`.
if (has_email_match) {
relevance += kPeopleEmailMatchScoreBoost();
}
// See comment for `kPeopleScoreBoost`.
relevance += kPeopleScoreBoost();
}
}
// See comment for `kPreferContentsOverQueries`.
if (suggestion_type ==
AutocompleteMatch::EnterpriseSearchAggregatorType::CONTENT &&
kPreferContentsOverQueries()) {
// 10 is small enough to not cause showing a worse CONTENT match over a
// better non-CONTENT match.
relevance += 10;
}
return {relevance, strong_word_matches, weak_word_matches, "local"};
}
} // namespace
EnterpriseSearchAggregatorProvider::RequestParsed::RequestParsed() = default;
EnterpriseSearchAggregatorProvider::RequestParsed::RequestParsed(
std::vector<AutocompleteMatch> matches,
size_t result_count)
: matches(std::move(matches)), result_count(result_count) {}
EnterpriseSearchAggregatorProvider::RequestParsed::~RequestParsed() = default;
EnterpriseSearchAggregatorProvider::RequestParsed::RequestParsed(
RequestParsed&&) noexcept = default;
EnterpriseSearchAggregatorProvider::RequestParsed&
EnterpriseSearchAggregatorProvider::RequestParsed::operator=(
RequestParsed&&) noexcept = default;
void EnterpriseSearchAggregatorProvider::RequestParsed::Append(
RequestParsed parsed) {
std::ranges::move(parsed.matches, std::back_inserter(matches));
result_count += parsed.result_count;
}
EnterpriseSearchAggregatorProvider::Request::Request(
std::vector<SuggestionType> types)
: types_(types) {}
EnterpriseSearchAggregatorProvider::Request::~Request() = default;
EnterpriseSearchAggregatorProvider::Request::Request(Request&&) = default;
bool EnterpriseSearchAggregatorProvider::Request::Allowed(
bool in_keyword_mode) const {
// Query requests are only allowed in keyword mode.
return !base::Contains(types_, SuggestionType::QUERY) || in_keyword_mode;
}
void EnterpriseSearchAggregatorProvider::Request::Reset(
bool clear_cached_matches) {
// If this request is interrupted, log its metrics now. Completed requests
// will have already logged their metrics on completion.
if (state_ == RequestState::kStarted) {
Log(/*interrupted=*/true);
}
// Iff retaining cached matches, then this request is still allowed and is
// expected to start.
state_ = clear_cached_matches ? RequestState::kCompleted
: RequestState::kNotStarted;
start_time_ = {};
loader_.reset();
// Don't clear `matches_` so old matches can be shown until the new response
// is received and parsed.
if (clear_cached_matches)
parsed_ = {};
}
void EnterpriseSearchAggregatorProvider::Request::OnStart(
std::unique_ptr<network::SimpleURLLoader> loader) {
CHECK_EQ(state_, RequestState::kNotStarted);
state_ = RequestState::kStarted;
start_time_ = base::TimeTicks::Now();
loader_ = std::move(loader);
}
void EnterpriseSearchAggregatorProvider::Request::OnCompleted(
RequestParsed parsed) {
CHECK_EQ(state_, RequestState::kStarted);
state_ = RequestState::kCompleted;
loader_.reset();
parsed_ = std::move(parsed);
Log(/*interrupted=*/false);
}
const std::vector<EnterpriseSearchAggregatorProvider::SuggestionType>
EnterpriseSearchAggregatorProvider::Request::Types() const {
return types_;
}
std::vector<int>
EnterpriseSearchAggregatorProvider::Request::BackendSuggestionTypes() const {
std::vector<int> backend_types = {};
for (SuggestionType type : types_) {
switch (type) {
case SuggestionType::NONE:
NOTREACHED();
case SuggestionType::QUERY:
backend_types.push_back(1);
break;
case SuggestionType::PEOPLE:
backend_types.push_back(2);
break;
case SuggestionType::CONTENT:
backend_types.push_back(3);
backend_types.push_back(5);
break;
}
}
return backend_types;
}
EnterpriseSearchAggregatorProvider::RequestState
EnterpriseSearchAggregatorProvider::Request::State() const {
return state_;
}
base::TimeTicks EnterpriseSearchAggregatorProvider::Request::StartTime() const {
return start_time_;
}
const std::vector<AutocompleteMatch>&
EnterpriseSearchAggregatorProvider::Request::Matches() const {
return parsed_.matches;
}
int EnterpriseSearchAggregatorProvider::Request::ResultCount() const {
// Only completed requests log result counts.
CHECK_EQ(state_, RequestState::kCompleted);
return parsed_.result_count;
}
// static
void EnterpriseSearchAggregatorProvider::Request::LogResponseTime(
const std::string& type_histogram_suffix,
bool interrupted,
base::TimeTicks start_time) {
const std::string kResponseTimeHistogramName =
"Omnibox.SuggestRequestsSent.ResponseTime2.RequestState";
const std::string kEnterpriseRequestTypeString =
"EnterpriseSearchAggregatorSuggest";
const base::TimeDelta elapsed_time = base::TimeTicks::Now() - start_time;
base::UmaHistogramTimes(
base::StringPrintf("%s.%s%s.%s", kResponseTimeHistogramName,
kEnterpriseRequestTypeString, type_histogram_suffix,
interrupted ? "Interrupted" : "Completed"),
elapsed_time);
base::UmaHistogramTimes(
base::StringPrintf("%s.%s%s", kResponseTimeHistogramName,
kEnterpriseRequestTypeString, type_histogram_suffix),
elapsed_time);
}
// static
void EnterpriseSearchAggregatorProvider::Request::LogResultCount(
const std::string& type_histogram_suffix,
int count) {
base::UmaHistogramExactLinear(
base::StringPrintf("Omnibox.SuggestRequestsSent.ResultCount."
"EnterpriseSearchAggregatorSuggest%s",
type_histogram_suffix),
count, 50);
}
void EnterpriseSearchAggregatorProvider::Request::Log(bool interrupted) const {
// When making a single request, logging X.PEOPLE would be redundant with just
// logging X.
if (!kMultipleRequests())
return;
std::string suffix = TypeHistogramSuffix();
LogResponseTime(suffix, interrupted, start_time_);
// Only completed requests log result counts.
if (!interrupted) {
LogResultCount(suffix, parsed_.result_count);
}
}
std::string EnterpriseSearchAggregatorProvider::Request::TypeHistogramSuffix()
const {
// Should not log type slices when making just a single request.
CHECK_EQ(types_.size(), 1u);
switch (types_[0]) {
case EnterpriseSearchAggregatorProvider::SuggestionType::PEOPLE:
return ".People";
case EnterpriseSearchAggregatorProvider::SuggestionType::CONTENT:
return ".Content";
case EnterpriseSearchAggregatorProvider::SuggestionType::QUERY:
return ".Query";
case EnterpriseSearchAggregatorProvider::SuggestionType::NONE:
NOTREACHED();
}
}
EnterpriseSearchAggregatorProvider::EnterpriseSearchAggregatorProvider(
AutocompleteProviderClient* client,
AutocompleteProviderListener* listener)
: AutocompleteProvider(
AutocompleteProvider::TYPE_ENTERPRISE_SEARCH_AGGREGATOR),
client_(client),
debouncer_(std::make_unique<AutocompleteProviderDebouncer>(true, 300)),
template_url_service_(client_->GetTemplateURLService()) {
AddListener(listener);
if (kMultipleRequests()) {
requests_.push_back(Request{{SuggestionType::QUERY}});
requests_.push_back(Request{{SuggestionType::PEOPLE}});
requests_.push_back(Request{{SuggestionType::CONTENT}});
} else {
requests_.push_back(Request{{SuggestionType::QUERY, SuggestionType::PEOPLE,
SuggestionType::CONTENT}});
}
}
EnterpriseSearchAggregatorProvider::~EnterpriseSearchAggregatorProvider() =
default;
void EnterpriseSearchAggregatorProvider::Start(const AutocompleteInput& input,
bool minimal_changes) {
// Don't clear matches. Keep showing old matches until a new response comes.
// This avoids flickering.
Stop(AutocompleteStopReason::kInteraction);
if (!IsProviderAllowed(input)) {
// Clear old matches if provider is not allowed.
for (auto& request : requests_) {
request.Reset(true);
}
matches_.clear();
return;
}
// No need to redo or restart the previous request/response if the input
// hasn't changed.
if (minimal_changes) {
return;
}
if (input.omit_asynchronous_matches()) {
return;
}
adjusted_input_ = input;
template_url_ = AdjustTemplateURL(&adjusted_input_, template_url_service_);
CHECK(template_url_);
CHECK(template_url_->policy_origin() ==
TemplateURLData::PolicyOrigin::kSearchAggregator);
// There should be no enterprise search suggestions fetched for on-focus
// suggestion requests, or if the input is empty. Don't check
// `OmniboxInputType::EMPTY` as the input's type isn't updated when keyword
// adjusting.
// TODO(crbug.com/393480150): Update this check once recent suggestions are
// supported.
if (adjusted_input_.IsZeroSuggest() || adjusted_input_.text().empty()) {
for (auto& request : requests_) {
request.Reset(true);
}
matches_.clear();
return;
}
done_ = false; // Set true in callbacks.
// Unretained is safe because `this` owns `debouncer_`.
debouncer_->RequestRun(base::BindOnce(
&EnterpriseSearchAggregatorProvider::Run, base::Unretained(this)));
}
void EnterpriseSearchAggregatorProvider::Stop(
AutocompleteStopReason stop_reason) {
// Ignore the stop timer since this provider is expected to sometimes take
// longer than 1500ms.
if (stop_reason == AutocompleteStopReason::kInactivity) {
return;
}
AutocompleteProvider::Stop(stop_reason);
debouncer_->CancelRequest();
// If any requests haven't completed, then the type-unsliced histograms still
// need to be logged. Otherwise, they were already logged when the last
// request completed.
if (std::any_of(requests_.begin(), requests_.end(), [](auto& request) {
return request.State() == RequestState::kStarted;
})) {
LogAllRequests(true);
}
// Stop requests that haven't been started yet.
if (auto* remote_suggestions_service = client_->GetRemoteSuggestionsService(
/*create_if_necessary=*/false)) {
remote_suggestions_service
->StopCreatingEnterpriseSearchAggregatorSuggestionsRequest();
}
// Stop ongoing requests but keep cached matches for ongoing and completed
// requests.
for (auto& request : requests_) {
request.Reset(false);
}
}
bool EnterpriseSearchAggregatorProvider::IsProviderAllowed(
const AutocompleteInput& input) {
// Don't start in incognito mode.
if (client_->IsOffTheRecord()) {
return false;
}
// Gate on "Improve Search Suggestions" setting.
if (!client_->SearchSuggestEnabled()) {
return false;
}
// There can be an aggregator set either through the feature params or through
// a policy JSON. Both require this feature to be enabled.
if (!omnibox_feature_configs::SearchAggregatorProvider::Get().enabled) {
return false;
}
// Don't run provider in non-keyword mode if query length is less than
// the minimum length.
if (!input.InKeywordMode() &&
static_cast<int>(input.text().length()) <
omnibox_feature_configs::SearchAggregatorProvider::Get()
.min_query_length) {
return false;
}
// Don't run provider if the input is a URL.
if (input.type() == metrics::OmniboxInputType::URL) {
return false;
}
if (input.current_page_classification() ==
metrics::OmniboxEventProto::NTP_REALBOX &&
!omnibox_feature_configs::SearchAggregatorProvider::Get()
.realbox_unscoped_suggestions) {
return false;
}
// TODO(crbug.com/380642693): Add backoff check.
return true;
}
void EnterpriseSearchAggregatorProvider::Run() {
std::vector<int> request_indexes = {};
std::vector<std::vector<int>> backend_suggestion_types = {};
for (size_t i = 0; i < requests_.size(); ++i) {
bool allowed = requests_[i].Allowed(adjusted_input_.InKeywordMode());
requests_[i].Reset(!allowed);
if (allowed) {
request_indexes.push_back(i);
backend_suggestion_types.push_back(requests_[i].BackendSuggestionTypes());
}
}
// Necessary to update `matches_` immediately if e.g. the user just
// entered/left keyword mode and query results should be removed/added.
AggregateMatches();
client_->GetRemoteSuggestionsService(/*create_if_necessary=*/true)
->CreateEnterpriseSearchAggregatorSuggestionsRequest(
adjusted_input_.text(), GURL(template_url_->suggestions_url()),
adjusted_input_.current_page_classification(), request_indexes,
backend_suggestion_types,
base::BindRepeating(
&EnterpriseSearchAggregatorProvider::RequestStarted,
weak_ptr_factory_.GetWeakPtr()),
base::BindRepeating(
&EnterpriseSearchAggregatorProvider::RequestCompleted,
base::Unretained(this) /* this owns SimpleURLLoader */));
}
void EnterpriseSearchAggregatorProvider::RequestStarted(
int request_index,
std::unique_ptr<network::SimpleURLLoader> loader) {
requests_[request_index].OnStart(std::move(loader));
}
void EnterpriseSearchAggregatorProvider::RequestCompleted(
int request_index,
const network::SimpleURLLoader* source,
int response_code,
std::unique_ptr<std::string> response_body) {
DCHECK(!done_);
DCHECK_GE(requests_.size(), static_cast<size_t>(request_index));
if (response_code == 200) {
// Parse `response_body` in utility process if feature param is true.
const std::string& json_data = SearchSuggestionParser::ExtractJsonData(
source, std::move(response_body));
if (omnibox_feature_configs::SearchAggregatorProvider::Get()
.parse_response_in_utility_process) {
data_decoder::DataDecoder::ParseJsonIsolated(
json_data,
base::BindOnce(
&EnterpriseSearchAggregatorProvider::OnJsonParsedIsolated,
base::Unretained(this), request_index));
} else {
std::optional<base::Value::Dict> value = base::JSONReader::ReadDict(
json_data, base::JSON_ALLOW_TRAILING_COMMAS);
HandleParsedJson(request_index, value);
}
} else {
HandleParsedJson(request_index, std::nullopt);
}
}
void EnterpriseSearchAggregatorProvider::OnJsonParsedIsolated(
int request_index,
base::expected<base::Value, std::string> result) {
std::optional<base::Value::Dict> value = std::nullopt;
if (result.has_value() && result.value().is_dict()) {
value = std::move(result.value().GetDict());
}
HandleParsedJson(request_index, value);
}
void EnterpriseSearchAggregatorProvider::HandleParsedJson(
int request_index,
const std::optional<base::Value::Dict>& response_value) {
RequestParsed parsed =
response_value.has_value()
? ParseEnterpriseSearchAggregatorSearchResults(
requests_[request_index].Types(), response_value.value())
: RequestParsed{};
requests_[request_index].OnCompleted(std::move(parsed));
AggregateMatches();
}
EnterpriseSearchAggregatorProvider::RequestParsed
EnterpriseSearchAggregatorProvider::
ParseEnterpriseSearchAggregatorSearchResults(
const std::vector<SuggestionType>& suggestion_types,
const base::Value::Dict& root_val) {
// Break the input into words to avoid redoing this for every match.
std::set<std::u16string> input_words = GetWords({adjusted_input_.text()});
// Parse the results.
const base::Value::List* queryResults = root_val.FindList("querySuggestions");
const base::Value::List* peopleResults =
root_val.FindList("peopleSuggestions");
const base::Value::List* contentResults =
root_val.FindList("contentSuggestions");
RequestParsed parsed{};
if (base::Contains(suggestion_types, SuggestionType::QUERY)) {
parsed.Append(ParseResultList(input_words, queryResults,
/*suggestion_type=*/SuggestionType::QUERY,
/*is_navigation=*/false));
}
if (base::Contains(suggestion_types, SuggestionType::PEOPLE)) {
parsed.Append(ParseResultList(input_words, peopleResults,
/*suggestion_type=*/SuggestionType::PEOPLE,
/*is_navigation=*/true));
}
if (base::Contains(suggestion_types, SuggestionType::CONTENT)) {
parsed.Append(ParseResultList(input_words, contentResults,
/*suggestion_type=*/SuggestionType::CONTENT,
/*is_navigation=*/true));
}
return parsed;
}
EnterpriseSearchAggregatorProvider::RequestParsed
EnterpriseSearchAggregatorProvider::ParseResultList(
std::set<std::u16string> input_words,
const base::Value::List* results,
SuggestionType suggestion_type,
bool is_navigation) {
if (!results) {
return {};
}
// Limit # of matches created. See comment for `kMaxMatchesCreatedPerType`.
size_t num_results = std::min(results->size(), kMaxMatchesCreatedPerType());
ACMatches matches;
for (size_t i = 0; i < num_results; i++) {
const base::Value& result_value = (*results)[i];
if (!result_value.is_dict()) {
continue;
}
const base::Value::Dict& result = result_value.GetDict();
auto url = GetMatchDestinationUrl(result, suggestion_type);
// All matches must have a URL.
if (url.empty()) {
continue;
}
// Some matches are supplied with an associated icon or image URL.
std::string image_url;
std::string icon_url;
if (suggestion_type == SuggestionType::PEOPLE) {
// For people suggestions, `icon_url` must always be set to the favicon
// for the TemplateURL, which is used as the Omnibox icon. `image_url` is
// used for the match icon, falling back to the favicon if not present.
image_url = ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.displayPhoto.url"));
// Ensure that image URLs from lh3.googleusercontent.com include an image
// size parameter.
if (base::StartsWith(image_url, "https://lh3.googleusercontent.com")) {
// Check for existing size parameters (e.g., -s128, =w256, -h64).
RE2 size_regex("=(?:[swh]\\d+|[^=]*?-[swh]\\d+)");
if (!RE2::PartialMatch(image_url, size_regex)) {
image_url += base::Contains(image_url, "=") ? "-s64" : "=s64";
}
}
icon_url = template_url_->favicon_url().spec();
} else if (suggestion_type == SuggestionType::CONTENT) {
icon_url = ptr_to_string(result.FindString("iconUri"));
} else if (suggestion_type == SuggestionType::QUERY &&
!adjusted_input_.InKeywordMode()) {
icon_url = template_url_->favicon_url().spec();
}
auto description = GetMatchDescription(result, suggestion_type);
// Nav matches must have a description.
if (is_navigation && description.empty()) {
continue;
}
auto contents = GetMatchContents(result, suggestion_type);
// Search matches must have contents.
if (!is_navigation && contents.empty()) {
continue;
}
EnterpriseSearchAggregatorProvider::RelevanceData relevance_data;
std::string relevance_scoring_mode =
omnibox_feature_configs::SearchAggregatorProvider::Get()
.relevance_scoring_mode;
// If mode is `server|client`, always use server|client scoring; otherwise,
// use server scoring in scoped mode, and client scoring in unscoped mode.
if (relevance_scoring_mode == "server" ||
(relevance_scoring_mode != "client" &&
adjusted_input_.InKeywordMode())) {
relevance_data = GetServerRelevanceData(result);
} else {
const std::vector<std::u16string> email_usernames =
GetEmailUsernames(result);
auto strong_scoring_fields = GetStrongScoringFields(
result, suggestion_type, contents, description, email_usernames);
auto weak_scoring_fields = GetWeakScoringFields(result, suggestion_type);
relevance_data = CalculateRelevanceData(
input_words, adjusted_input_.InKeywordMode(), suggestion_type,
strong_scoring_fields, weak_scoring_fields, email_usernames);
}
if (relevance_data.relevance) {
// Decrement scores to keep sorting stable. Add 10 to avoid going below
// "weak" threshold or change the hundred's digit; e.g. a score of
// 600 v 599 could drastically affect the match's omnibox ranking.
relevance_data.relevance += 10 - matches.size();
}
std::u16string fill_into_edit;
if (adjusted_input_.InKeywordMode()) {
fill_into_edit.append(template_url_->keyword() + u' ');
}
fill_into_edit.append(base::UTF8ToUTF16(is_navigation ? url : contents));
matches.push_back(CreateMatch(suggestion_type, is_navigation,
relevance_data, url, image_url, icon_url,
base::UTF8ToUTF16(description),
base::UTF8ToUTF16(contents), fill_into_edit));
}
// Limit # of matches added. See comment for
// `kMaxScopedMatchesShownPerType`.
size_t matches_to_add = adjusted_input_.InKeywordMode()
? kMaxScopedMatchesShownPerType()
: kMaxUnscopedMatchesShownPerType();
if (matches_to_add < matches.size()) {
std::ranges::partial_sort(matches, matches.begin() + matches_to_add,
std::ranges::greater{},
&AutocompleteMatch::relevance);
matches.erase(matches.begin() + matches_to_add, matches.end());
}
return {std::move(matches), results->size()};
}
std::string EnterpriseSearchAggregatorProvider::GetMatchDestinationUrl(
const base::Value::Dict& result,
SuggestionType suggestion_type) const {
std::string destination_uri =
ptr_to_string(result.FindString("destinationUri"));
if (suggestion_type == SuggestionType::CONTENT ||
suggestion_type == SuggestionType::PEOPLE) {
return destination_uri;
}
std::string query = ptr_to_string(result.FindString("suggestion"));
if (query.empty()) {
return "";
}
const TemplateURLRef& url_ref = template_url_->url_ref();
return url_ref.ReplaceSearchTerms(
TemplateURLRef::SearchTermsArgs(base::UTF8ToUTF16(query)), {}, nullptr);
}
std::string EnterpriseSearchAggregatorProvider::GetMatchDescription(
const base::Value::Dict& result,
SuggestionType suggestion_type) const {
if (suggestion_type == SuggestionType::PEOPLE) {
return ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.name.displayName"));
} else if (suggestion_type == SuggestionType::CONTENT) {
return ptr_to_string(
result.FindStringByDottedPath("document.derivedStructData.title"));
}
return "";
}
std::string EnterpriseSearchAggregatorProvider::GetMatchContents(
const base::Value::Dict& result,
SuggestionType suggestion_type) const {
if (suggestion_type == SuggestionType::QUERY) {
return ptr_to_string(result.FindString("suggestion"));
} else if (suggestion_type == SuggestionType::PEOPLE) {
return l10n_util::GetStringFUTF8(
IDS_PERSON_SUGGESTION_DESCRIPTION,
template_url_->AdjustedShortNameForLocaleDirection());
} else if (suggestion_type == SuggestionType::CONTENT) {
std::optional<int> response_time =
result.FindIntByDottedPath("document.derivedStructData.updated_time");
const std::u16string last_updated = UpdateTimeToString(response_time);
const std::u16string owner = base::UTF8ToUTF16(ptr_to_string(
result.FindStringByDottedPath("document.derivedStructData.owner")));
const std::u16string content_type_description = ContentTypeToDescription(
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.mime_type")),
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.source_type")));
return base::UTF16ToUTF8(GetLocalizedContentMetadata(
last_updated, owner, content_type_description));
}
return "";
}
std::u16string EnterpriseSearchAggregatorProvider::GetLocalizedContentMetadata(
const std::u16string& update_time,
const std::u16string& owner,
const std::u16string& content_type_description) const {
if (!update_time.empty()) {
if (!owner.empty()) {
return !content_type_description.empty()
? l10n_util::GetStringFUTF16(
IDS_CONTENT_SUGGESTION_DESCRIPTION_TEMPLATE, update_time,
owner, content_type_description)
: l10n_util::GetStringFUTF16(
IDS_CONTENT_SUGGESTION_DESCRIPTION_TEMPLATE_WITHOUT_FILE_TYPE_DESCRIPTION,
update_time, owner);
}
return !content_type_description.empty()
? l10n_util::GetStringFUTF16(
IDS_CONTENT_SUGGESTION_DESCRIPTION_TEMPLATE_WITHOUT_OWNER,
update_time, content_type_description)
: update_time;
}
if (!owner.empty()) {
return !content_type_description.empty()
? l10n_util::GetStringFUTF16(
IDS_CONTENT_SUGGESTION_DESCRIPTION_TEMPLATE_WITHOUT_DATE,
owner, content_type_description)
: owner;
}
return !content_type_description.empty() ? content_type_description : u"";
}
std::vector<std::string>
EnterpriseSearchAggregatorProvider::GetStrongScoringFields(
const base::Value::Dict& result,
SuggestionType suggestion_type,
const std::string& contents,
const std::string& description,
const std::vector<std::u16string> email_usernames) const {
std::vector<std::string> strong_scoring_fields;
// Should not return any fields already included in `GetMatchDescription()` &
// `GetMatchContents()`.
if (suggestion_type == SuggestionType::PEOPLE) {
std::ranges::transform(
email_usernames, std::back_inserter(strong_scoring_fields),
[](const auto& u16string) { return base::UTF16ToUTF8(u16string); });
} else {
// Contents field for people suggestions is always "{NAME} People" and is
// not a good field to use to score relevancy.
strong_scoring_fields.push_back(contents);
}
strong_scoring_fields.push_back(description);
return strong_scoring_fields;
}
std::vector<std::string>
EnterpriseSearchAggregatorProvider::GetWeakScoringFields(
const base::Value::Dict& result,
SuggestionType suggestion_type) const {
// Should not return any fields already included in `GetMatchDescription()` &
// `GetMatchContents()`.
if (suggestion_type == SuggestionType::PEOPLE) {
return {
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.name.givenName")),
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.name.familyName")),
};
} else if (suggestion_type == SuggestionType::CONTENT) {
return {
ptr_to_string(
result.FindStringByDottedPath("document.derivedStructData.owner")),
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.mime_type")),
ptr_to_string(result.FindStringByDottedPath(
"document.derivedStructData.owner_email")),
};
}
return {};
}
AutocompleteMatch EnterpriseSearchAggregatorProvider::CreateMatch(
SuggestionType suggestion_type,
bool is_navigation,
RelevanceData relevance_data,
const std::string& url,
const std::string& image_url,
const std::string& icon_url,
const std::u16string& description,
const std::u16string& contents,
const std::u16string& fill_into_edit) {
auto type = is_navigation ? AutocompleteMatchType::NAVSUGGEST
: AutocompleteMatchType::SEARCH_SUGGEST;
AutocompleteMatch match(this, relevance_data.relevance, false, type);
match.destination_url = GURL(url);
if (!image_url.empty()) {
match.image_url = GURL(image_url);
}
if (!icon_url.empty()) {
match.icon_url = GURL(icon_url);
}
match.enterprise_search_aggregator_type = suggestion_type;
match.description = AutocompleteMatch::SanitizeString(description);
match.contents = AutocompleteMatch::SanitizeString(contents);
if (!is_navigation) {
match.search_terms_args =
std::make_unique<TemplateURLRef::SearchTermsArgs>(match.contents);
}
// `NAVSUGGEST` is displayed "<description> - <contents>" and
// `SEARCH_SUGGEST` is displayed "<contents> - <description>".
// The below code formats `description` and `contents` accordingly.
auto primary_text_class = [&](auto text) {
return ClassifyTermMatches(FindTermMatches(adjusted_input_.text(), text),
text.size(), ACMatchClassification::MATCH,
ACMatchClassification::NONE);
};
ACMatchClassifications secondary_text_class =
(contents.empty() || description.empty())
? std::vector<ACMatchClassification>{}
: std::vector<ACMatchClassification>{{0, ACMatchClassification::DIM}};
match.description_class = is_navigation
? primary_text_class(match.description)
: secondary_text_class;
match.contents_class =
is_navigation ? secondary_text_class : primary_text_class(match.contents);
match.fill_into_edit = fill_into_edit;
match.keyword = template_url_->keyword();
match.transition = adjusted_input_.InKeywordMode()
? ui::PAGE_TRANSITION_KEYWORD
: ui::PAGE_TRANSITION_GENERATED;
if (adjusted_input_.InKeywordMode()) {
match.from_keyword = true;
}
match.RecordAdditionalInfo("aggregator type",
static_cast<int>(suggestion_type));
match.RecordAdditionalInfo(
"relevance strong word matches",
static_cast<int>(relevance_data.strong_word_matches));
match.RecordAdditionalInfo(
"relevance weak word matches",
static_cast<int>(relevance_data.weak_word_matches));
match.RecordAdditionalInfo("relevance source", relevance_data.source);
return match;
}
void EnterpriseSearchAggregatorProvider::AggregateMatches() {
// Aggregate matches from `requests_` to `matches_`.
matches_.clear();
for (auto& request : requests_) {
std::ranges::copy(request.Matches(), std::back_inserter(matches_));
}
// Limit low-quality suggestions. See comment for
// `kScopedMaxLowQualityMatches`.
std::ranges::sort(matches_, std::ranges::greater{},
&AutocompleteMatch::relevance);
size_t matches_to_keep = adjusted_input_.InKeywordMode()
? kScopedMaxLowQualityMatches()
: kUnscopedMaxLowQualityMatches();
if (matches_.size() > matches_to_keep) {
for (; matches_to_keep < matches_.size(); ++matches_to_keep) {
if (matches_[matches_to_keep].relevance < kLowQualityThreshold()) {
break;
}
}
matches_.erase(matches_.begin() + matches_to_keep, matches_.end());
}
// If all requests completed, then log the type-unsliced histograms.
if (std::all_of(requests_.begin(), requests_.end(), [](auto& request) {
return request.State() == RequestState::kCompleted;
})) {
LogAllRequests(false);
done_ = true;
}
NotifyListeners(/*updated_matches=*/true);
}
void EnterpriseSearchAggregatorProvider::LogAllRequests(bool interrupted) {
base::TimeTicks earliest_start_time =
std::ranges::min_element(requests_, {}, &Request::StartTime)->StartTime();
Request::LogResponseTime(/*type_histogram_suffix=*/"", interrupted,
earliest_start_time);
// Only completed requests log result counts.
if (!interrupted) {
int total_result_count = 0;
for (auto& request : requests_) {
total_result_count += request.ResultCount();
}
Request::LogResultCount(/*type_histogram_suffix=*/"", total_result_count);
}
}