0

Clickiness: Wire response from URLLoader to DB, add e2e tests

Checking if origins are allowed (allowed by user settings, attested,
etc.) will come in a subsequent CL.

Doesn't include the redirection logic -- that will land in crrev.com/c/6353624.

Bug: 394108643
Change-Id: Ic3897262c17afa7a7cbae967b9f266277ea77a80
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6336937
Auto-Submit: Caleb Raitto <caraitto@chromium.org>
Commit-Queue: Yuwei Huang <yuweih@chromium.org>
Reviewed-by: Maks Orlovich <morlovich@chromium.org>
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Reviewed-by: James Cook <jamescook@chromium.org>
Reviewed-by: Yuwei Huang <yuweih@chromium.org>
Reviewed-by: Austin Sullivan <asully@chromium.org>
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1432935}
This commit is contained in:
Caleb Raitto
2025-03-14 12:34:02 -07:00
committed by Chromium LUCI CQ
parent b07519b2f7
commit c321aa7496
25 changed files with 565 additions and 13 deletions

@ -268,6 +268,9 @@ void BruschettaNetworkContext::OnSharedStorageHeaderReceived(
std::move(callback).Run();
}
void BruschettaNetworkContext::OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) {}
void BruschettaNetworkContext::Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
observer) {

@ -92,6 +92,8 @@ class BruschettaNetworkContext
methods_with_options,
const std::optional<std::string>& with_lock,
OnSharedStorageHeaderReceivedCallback callback) override;
void OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) override;
void Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
listener) override;

@ -154,6 +154,12 @@ constexpr char kLegitimateAdAuctionSignals[] =
// throwing an exception.
const char kSuccess[] = "success";
// A path for an update registered by `RegisterNoOpUpdate()` that returns an
// empty dict, which is a valid update, but doesn't change the interest group.
// Can be used with
// WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating().
constexpr char kNoOpUpdatePath[] = "/interest_group/no_op_update_path.json";
// Returns a string that declares a "maybePromise()" Javascript function, which
// takes an argument and either returns it (if `use_promise` is false) or
// returns a promise that will be resolved with that value in a millisecond (if
@ -458,12 +464,14 @@ class NetworkResponder {
void RegisterNetworkResponse(const std::string& url_path,
std::string_view body,
std::string_view mime_type = "application/json",
ResponseHeaders extra_response_headers = {}) {
ResponseHeaders extra_response_headers = {},
net::HttpStatusCode code = net::HTTP_OK) {
base::AutoLock auto_lock(response_map_lock_);
Response response;
response.body = body;
response.mime_type = mime_type;
response.extra_response_headers = std::move(extra_response_headers);
response.code = code;
response_map_[url_path] = std::move(response);
}
@ -621,6 +629,7 @@ function generateBid(
std::string body;
std::string mime_type;
ResponseHeaders extra_response_headers;
net::HttpStatusCode code;
};
std::unique_ptr<net::test_server::HttpResponse> RequestHandler(
@ -632,7 +641,7 @@ function generateBid(
}
auto response = std::make_unique<net::test_server::BasicHttpResponse>();
response->AddCustomHeader(kFledgeHeader, "true");
response->set_code(net::HTTP_OK);
response->set_code(it->second.code);
response->set_content(it->second.body);
response->set_content_type(it->second.mime_type);
for (const auto& header : it->second.extra_response_headers) {
@ -767,7 +776,9 @@ class InterestGroupBrowserTest : public ContentBrowserTest {
{blink::features::kFledgeDirectFromSellerSignalsWebBundles, {}},
{blink::features::kFledgeTrustedSignalsKVv2Support, {}},
{blink::features::kFledgeTrustedSignalsKVv1CreativeScanning, {}},
{features::kFledgeTextConversionHelpers, {}}},
{features::kFledgeTextConversionHelpers, {}},
{network::features::kAdAuctionEventRegistration, {}},
{blink::features::kFledgeClickiness, {}}},
/*disabled_features=*/
{blink::features::kFencedFrames,
blink::features::kFledgeEnforceKAnonymity,
@ -969,6 +980,14 @@ class InterestGroupBrowserTest : public ContentBrowserTest {
dict.Set("trustedBiddingSignalsCoordinator",
group.trusted_bidding_signals_coordinator->Serialize());
}
if (group.view_and_click_counts_providers) {
base::Value::List providers;
for (const url::Origin& provider :
*group.view_and_click_counts_providers) {
providers.Append(provider.Serialize());
}
dict.Set("viewAndClickCountsProviders", std::move(providers));
}
if (group.user_bidding_signals) {
dict.Set("userBiddingSignals", JsonToValue(*group.user_bidding_signals));
}
@ -1517,6 +1536,41 @@ function provideAdditionalBids(seller, nonce, bidStringList,
}
}
// Waits until the `condition` callback over the interest groups returns
// true, running an update on the owner's groups in order to force interest
// group cache invalidation.
//
// This can be useful for loading things like clickiness data, whose updates
// intentionally don't invalidate cache.
//
// **REQUIREMENT**: At least one interest group owned by `owner` must have a
// valid updateURL and response registered, but the update response can be an
// empty dict. RegisterNoOpUpdate() and kNoOpUpdatePath can be used for this.
//
// Also, the current top-level page origin should be the same as `owner`.
void WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating(
const url::Origin& owner,
base::RepeatingCallback<bool(scoped_refptr<StorageInterestGroups>)>
condition) {
ASSERT_EQ(owner, shell()
->web_contents()
->GetPrimaryMainFrame()
->GetLastCommittedOrigin());
while (true) {
EXPECT_EQ("done", UpdateInterestGroupsInJS());
if (condition.Run(GetInterestGroupsForOwner(owner))) {
break;
}
}
}
// Registers an update at kNoOpUpdatePath that returns an empty dict, which
// is a valid update, but doesn't change the interest group. Can be used with
// WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating().
void RegisterNoOpUpdate() {
network_responder_->RegisterNetworkResponse(kNoOpUpdatePath, "{}");
}
// Waits for `url` to be requested by `embedded_https_test_server()`, or any
// other server that OnHttpsTestServerRequestMonitor() has been configured to
// monitor. `url`'s hostname is replaced with "127.0.0.1", since the embedded
@ -7891,6 +7945,219 @@ IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest,
EXPECT_TRUE(console_observer.Wait());
}
IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest, Clickiness_CaptureView) {
constexpr char kRecordViewClickPath[] =
"/interest_group/record_view_click_event.html";
GURL test_url_a = embedded_https_test_server().GetURL(
"a.test", "/attribution_reporting/page_with_impression_creator.html");
url::Origin test_origin_a = url::Origin::Create(test_url_a);
ASSERT_TRUE(test_url_a.SchemeIs(url::kHttpsScheme));
ASSERT_TRUE(NavigateToURL(shell(), test_url_a));
const std::string record_event_response = base::StringPrintf(
"type=\"view\", eligible-origins=(\"%s\")", test_origin_a.Serialize());
network_responder_->RegisterNetworkResponse(
kRecordViewClickPath, "Throwaway response", "image/jpeg",
/*extra_response_headers=*/
{{"Ad-Auction-Record-Event", record_event_response}});
GURL record_event_url =
embedded_https_test_server().GetURL("c.test", kRecordViewClickPath);
EXPECT_TRUE(ExecJs(web_contents(), JsReplace("createAttributionSrcImg($1);",
record_event_url)));
// This join should succeed. Register a no-op update URL to use
// WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating().
RegisterNoOpUpdate();
EXPECT_EQ(kSuccess, JoinInterestGroupAndVerify(
blink::TestInterestGroupBuilder(test_origin_a, "cars")
.SetViewAndClickCountsProviders(
{{url::Origin::Create(record_event_url)}})
.SetUpdateUrl(embedded_https_test_server().GetURL(
"a.test", kNoOpUpdatePath))
.Build()));
WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating(
test_origin_a,
base::BindLambdaForTesting(
[](scoped_refptr<StorageInterestGroups> groups) {
EXPECT_EQ(groups->size(), 1u);
const StorageInterestGroup& group = *groups->GetInterestGroups()[0];
const blink::mojom::ViewAndClickCountsPtr& view_and_click_counts =
group.bidding_browser_signals->view_and_click_counts;
EXPECT_EQ(group.interest_group.name, "cars");
return view_and_click_counts->view_counts->past_hour == 1 &&
view_and_click_counts->click_counts->past_hour == 0;
}));
// TODO(crbug.com/394108643): Also check generateBid() once the plumbing is
// hooked up.
}
IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest, Clickiness_CaptureClick) {
constexpr char kRecordViewClickPath[] =
"/interest_group/record_view_click_event.html";
GURL test_url_a = embedded_https_test_server().GetURL(
"a.test", "/attribution_reporting/page_with_impression_creator.html");
url::Origin test_origin_a = url::Origin::Create(test_url_a);
ASSERT_TRUE(test_url_a.SchemeIs(url::kHttpsScheme));
ASSERT_TRUE(NavigateToURL(shell(), test_url_a));
const std::string record_event_response = base::StringPrintf(
"type=\"click\", eligible-origins=(\"%s\")", test_origin_a.Serialize());
network_responder_->RegisterNetworkResponse(
kRecordViewClickPath, "Throwaway response", "image/jpeg",
/*extra_response_headers=*/
{{"Ad-Auction-Record-Event", record_event_response}});
GURL record_event_url =
embedded_https_test_server().GetURL("c.test", kRecordViewClickPath);
EXPECT_TRUE(
ExecJs(web_contents(),
JsReplace(R"(
createAttributionSrcAnchor({id: 'link',
url: $1,
attributionsrc: $2,
target: $3});)",
embedded_https_test_server().GetURL("a.test", "/echo"),
record_event_url, "_top")));
TestNavigationObserver observer(web_contents());
EXPECT_TRUE(ExecJs(web_contents(), "simulateClick('link');"));
observer.Wait();
// This join should succeed. Register a no-op update URL to use
// WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating().
RegisterNoOpUpdate();
EXPECT_EQ(kSuccess, JoinInterestGroupAndVerify(
blink::TestInterestGroupBuilder(test_origin_a, "cars")
.SetViewAndClickCountsProviders(
{{url::Origin::Create(record_event_url)}})
.SetUpdateUrl(embedded_https_test_server().GetURL(
"a.test", kNoOpUpdatePath))
.Build()));
WaitForInterestGroupsSatisfyingInvalidatingCacheByUpdating(
test_origin_a,
base::BindLambdaForTesting(
[](scoped_refptr<StorageInterestGroups> groups) {
EXPECT_EQ(groups->size(), 1u);
const StorageInterestGroup& group = *groups->GetInterestGroups()[0];
const blink::mojom::ViewAndClickCountsPtr& view_and_click_counts =
group.bidding_browser_signals->view_and_click_counts;
EXPECT_EQ(group.interest_group.name, "cars");
return view_and_click_counts->view_counts->past_hour == 0 &&
view_and_click_counts->click_counts->past_hour == 1;
}));
// TODO(crbug.com/394108643): Also check generateBid() once the plumbing is
// hooked up.
}
IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest, Clickiness_NotStructuredDict) {
constexpr char kRecordViewClickPath[] =
"/interest_group/record_view_click_event.html";
GURL test_url_a = embedded_https_test_server().GetURL(
"a.test", "/attribution_reporting/page_with_impression_creator.html");
url::Origin test_origin_a = url::Origin::Create(test_url_a);
ASSERT_TRUE(test_url_a.SchemeIs(url::kHttpsScheme));
ASSERT_TRUE(NavigateToURL(shell(), test_url_a));
// Double equals isn't valid in structured headers -- this message should be
// rejected.
const std::string record_event_response = base::StringPrintf(
"type==\"view\", eligible-origins=(\"%s\")", test_origin_a.Serialize());
network_responder_->RegisterNetworkResponse(
kRecordViewClickPath, "Throwaway response", "image/jpeg",
/*extra_response_headers=*/
{{"Ad-Auction-Record-Event", record_event_response}});
GURL record_event_url =
embedded_https_test_server().GetURL("c.test", kRecordViewClickPath);
EXPECT_TRUE(ExecJs(web_contents(), JsReplace("createAttributionSrcImg($1);",
record_event_url)));
// This join should succeed.
base::RunLoop().RunUntilIdle();
EXPECT_EQ(kSuccess, JoinInterestGroupAndVerify(
blink::TestInterestGroupBuilder(test_origin_a, "cars")
.SetViewAndClickCountsProviders(
{{url::Origin::Create(record_event_url)}})
.Build()));
// No change to view or click counts. Note that this is somewhat racy, as if
// the product code erroneously records a view or a click, it could happen
// after the join -- in that case, failures may be flaky. However, correct
// product code shouldn't result in flakes.
scoped_refptr<StorageInterestGroups> groups =
GetInterestGroupsForOwner(test_origin_a);
ASSERT_EQ(groups->size(), 1u);
const StorageInterestGroup& group = *groups->GetInterestGroups()[0];
const blink::mojom::ViewAndClickCountsPtr& view_and_click_counts =
group.bidding_browser_signals->view_and_click_counts;
EXPECT_EQ(group.interest_group.name, "cars");
EXPECT_EQ(view_and_click_counts->view_counts->past_hour, 0);
EXPECT_EQ(view_and_click_counts->click_counts->past_hour, 0);
}
IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest, Clickiness_InvalidType) {
constexpr char kRecordViewClickPath[] =
"/interest_group/record_view_click_event.html";
GURL test_url_a = embedded_https_test_server().GetURL(
"a.test", "/attribution_reporting/page_with_impression_creator.html");
url::Origin test_origin_a = url::Origin::Create(test_url_a);
ASSERT_TRUE(test_url_a.SchemeIs(url::kHttpsScheme));
ASSERT_TRUE(NavigateToURL(shell(), test_url_a));
// A valid structured dictionary, but not-view-or-click isn't a valid type --
// this message should be rejected.
const std::string record_event_response = base::StringPrintf(
"type=\"not-view-or-click\", eligible-origins=(\"%s\")",
test_origin_a.Serialize());
network_responder_->RegisterNetworkResponse(
kRecordViewClickPath, "Throwaway response", "image/jpeg",
/*extra_response_headers=*/
{{"Ad-Auction-Record-Event", record_event_response}});
GURL record_event_url =
embedded_https_test_server().GetURL("c.test", kRecordViewClickPath);
EXPECT_TRUE(ExecJs(web_contents(), JsReplace("createAttributionSrcImg($1);",
record_event_url)));
// This join should succeed.
base::RunLoop().RunUntilIdle();
EXPECT_EQ(kSuccess, JoinInterestGroupAndVerify(
blink::TestInterestGroupBuilder(test_origin_a, "cars")
.SetViewAndClickCountsProviders(
{{url::Origin::Create(record_event_url)}})
.Build()));
// No change to view or click counts. Note that this is somewhat racy, as if
// the product code erroneously records a view or a click, it could happen
// after the join -- in that case, failures may be flaky. However, correct
// product code shouldn't result in flakes.
scoped_refptr<StorageInterestGroups> groups =
GetInterestGroupsForOwner(test_origin_a);
ASSERT_EQ(groups->size(), 1u);
const StorageInterestGroup& group = *groups->GetInterestGroups()[0];
const blink::mojom::ViewAndClickCountsPtr& view_and_click_counts =
group.bidding_browser_signals->view_and_click_counts;
EXPECT_EQ(group.interest_group.name, "cars");
EXPECT_EQ(view_and_click_counts->view_counts->past_hour, 0);
EXPECT_EQ(view_and_click_counts->click_counts->past_hour, 0);
}
IN_PROC_BROWSER_TEST_F(InterestGroupBrowserTest,
RunAdAuctionBuyersNoInterestGroup) {
GURL test_url = embedded_https_test_server().GetURL("a.test", "/echo");

@ -363,6 +363,23 @@ void InterestGroupCachingStorage::RecordDebugReportCooldown(
.WithArgs(origin, cooldown_start, cooldown_type);
}
void InterestGroupCachingStorage::RecordViewClick(
network::AdAuctionEventRecord event_record) {
// Cached interest groups containing stale view / click counts are
// intentionally not evicted -- views especially occur frequently, and would
// result in many evictions, limiting the usefulness of this cache. So, for
// performance, it is better to return view / click data that's slightly
// stale.
//
// TODO(crbug.com/394108643): Cap the time duration of this staleness with a
// new timer that evicts groups loaded more than say 120 seconds ago. Without
// this, in the rare case that auctions that each load a given IG are running
// constantly, back-to-back, the view click data for that IG could become
// arbitrarily stale.
interest_group_storage_.AsyncCall(&InterestGroupStorage::RecordViewClick)
.WithArgs(std::move(event_record));
}
void InterestGroupCachingStorage::UpdateKAnonymity(
const blink::InterestGroupKey& interest_group_key,
const std::vector<std::string>& positive_hashed_keys,

@ -225,6 +225,9 @@ class CONTENT_EXPORT InterestGroupCachingStorage {
void RecordDebugReportCooldown(const url::Origin& origin,
base::Time cooldown_start,
DebugReportCooldownType cooldown_type);
// Records a view or a click event. Aggregate time bucketed view and click
// information is provided to bidder's browsing signals in generateBid().
void RecordViewClick(network::AdAuctionEventRecord event_record);
// Records a K-anonymity update for an interest group. If
// `replace_existing_values` is true, this update will store the new
// `update_time` and `positive_hashed_values`, replacing the interest

@ -524,6 +524,19 @@ void InterestGroupManagerImpl::RecordDebugReportCooldown(
cooldown_type);
}
void InterestGroupManagerImpl::RecordViewClick(
network::AdAuctionEventRecord event_record) {
// TODO(crbug.com/394108643): Check against
// ContentBrowserClient::IsInterestGroupAPIAllowed(). This will require
// getting an RFH; we could use something like
// url_loader_network_observers_.current_context().navigation_or_document()
// in the StoragePartitionImpl, but the issue is that for clicks, we'll
// probably get a navigation handle instead of an RFH? Perhaps we can
// override WebContentsObserver::DidFinishNavigation() to wait until the new
// RFH is ready?
caching_storage_.RecordViewClick(std::move(event_record));
}
void InterestGroupManagerImpl::RegisterAdKeysAsJoined(
base::flat_set<std::string> hashed_keys) {
k_anonymity_manager_->RegisterAdKeysAsJoined(std::move(hashed_keys));

@ -280,6 +280,10 @@ class CONTENT_EXPORT InterestGroupManagerImpl : public InterestGroupManager {
base::Time cooldown_start,
DebugReportCooldownType cooldown_type);
// Records a view or a click event. Aggregate time bucketed view and click
// information is provided to bidder's browsing signals in generateBid().
void RecordViewClick(network::AdAuctionEventRecord event_record);
// Reports the ad keys to the k-anonymity service. Should be called when
// FLEDGE selects an ad.
void RegisterAdKeysAsJoined(base::flat_set<std::string> hashed_keys);

@ -342,6 +342,9 @@ void NetworkServiceClient::OnSharedStorageHeaderReceived(
std::move(callback).Run();
}
void NetworkServiceClient::OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) {}
void NetworkServiceClient::Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
observer) {

@ -132,6 +132,8 @@ class NetworkServiceClient
methods_with_options,
const std::optional<std::string>& with_lock,
OnSharedStorageHeaderReceivedCallback callback) override;
void OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) override;
void Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
listener) override;

@ -2370,6 +2370,11 @@ void StoragePartitionImpl::OnSharedStorageHeaderReceived(
std::move(callback), mojo::GetBadMessageCallback(), /*can_defer=*/true);
}
void StoragePartitionImpl::OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) {
interest_group_manager_->RecordViewClick(std::move(event_record));
}
void StoragePartitionImpl::Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
observer) {

@ -398,6 +398,8 @@ class CONTENT_EXPORT StoragePartitionImpl
methods_with_options,
const std::optional<std::string>& with_lock,
OnSharedStorageHeaderReceivedCallback callback) override;
void OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) override;
SharedStorageHeaderObserver* shared_storage_header_observer() {
return shared_storage_header_observer_.get();

@ -157,6 +157,9 @@ void UrlLoaderNetworkServiceObserver::OnSharedStorageHeaderReceived(
std::move(callback).Run();
}
void UrlLoaderNetworkServiceObserver::OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) {}
void UrlLoaderNetworkServiceObserver::Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
observer) {

@ -81,6 +81,8 @@ class UrlLoaderNetworkServiceObserver
methods_with_options,
const std::optional<std::string>& with_lock,
OnSharedStorageHeaderReceivedCallback callback) override;
void OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) override;
void Clone(
mojo::PendingReceiver<network::mojom::URLLoaderNetworkServiceObserver>
listener) override;

@ -330,6 +330,7 @@ component("network_service") {
"//mojo/public/cpp/system",
"//net",
"//net:extras",
"//services/network/ad_auction",
"//services/network/attribution",
"//services/network/first_party_sets:first_party_sets_manager",
"//services/network/public/cpp",

@ -0,0 +1,26 @@
# 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.
source_set("ad_auction") {
visibility = [
":*",
"//services/network:network_service",
"//services/network:test_support",
"//services/network:tests",
]
configs += [ "//build/config/compiler:wexit_time_destructors" ]
defines = [ "IS_NETWORK_SERVICE_IMPL" ]
sources = [
"event_record_request_helper.cc",
"event_record_request_helper.h",
]
deps = [
"//base",
"//net",
"//services/network/public/cpp",
"//services/network/public/cpp:ad_auction_support",
"//services/network/public/mojom",
"//url",
]
}

@ -0,0 +1,74 @@
// 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 "services/network/ad_auction/event_record_request_helper.h"
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "base/feature_list.h"
#include "base/notreached.h"
#include "net/http/structured_headers.h"
#include "net/url_request/url_request.h"
#include "services/network/public/cpp/ad_auction/event_record.h"
#include "services/network/public/cpp/features.h"
#include "services/network/public/mojom/ad_auction.mojom.h"
#include "services/network/public/mojom/attribution.mojom.h"
#include "services/network/public/mojom/url_loader_network_service_observer.mojom.h"
#include "url/gurl.h"
#include "url/origin.h"
namespace network {
void AdAuctionEventRecordRequestHelper::HandleResponse(
const net::URLRequest& request) {
if (!base::FeatureList::IsEnabled(features::kAdAuctionEventRegistration)) {
return;
}
AdAuctionEventRecord::Type expected_type;
switch (attribution_reporting_eligibility_) {
case mojom::AttributionReportingEligibility::kEventSource:
case mojom::AttributionReportingEligibility::kEventSourceOrTrigger:
expected_type = AdAuctionEventRecord::Type::kView;
break;
case mojom::AttributionReportingEligibility::kNavigationSource:
expected_type = AdAuctionEventRecord::Type::kClick;
break;
case mojom::AttributionReportingEligibility::kUnset:
case mojom::AttributionReportingEligibility::kEmpty:
case mojom::AttributionReportingEligibility::kTrigger:
// Nothing to do for these requests, they're not eligible for click or
// view events.
return;
}
std::optional<std::string> ad_auction_record_event_header =
AdAuctionEventRecord::GetAdAuctionRecordEventHeader(
request.response_headers());
if (!ad_auction_record_event_header) {
return;
}
std::optional<net::structured_headers::Dictionary> dict =
net::structured_headers::ParseDictionary(*ad_auction_record_event_header);
if (!dict) {
return;
}
std::optional<AdAuctionEventRecord> maybe_parsed =
AdAuctionEventRecord::MaybeCreateFromStructuredDict(
*dict, /*expected_type=*/expected_type,
/*providing_origin=*/url::Origin::Create(request.url()));
if (!maybe_parsed) {
return;
}
url_loader_network_observer_->OnAdAuctionEventRecordHeaderReceived(
std::move(*maybe_parsed));
}
} // namespace network

@ -0,0 +1,47 @@
// 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.
#ifndef SERVICES_NETWORK_AD_AUCTION_EVENT_RECORD_REQUEST_HELPER_H_
#define SERVICES_NETWORK_AD_AUCTION_EVENT_RECORD_REQUEST_HELPER_H_
#include <string_view>
#include "base/memory/raw_ref.h"
#include "net/url_request/url_request.h"
#include "services/network/public/mojom/ad_auction.mojom-forward.h"
#include "services/network/public/mojom/attribution.mojom.h"
#include "services/network/public/mojom/url_loader_network_service_observer.mojom-forward.h"
namespace network {
// When constructed for requests with eligible values of
// `attribution_reporting_eligibility`, parses Ad-Auction-Record-Event response
// headers headers and sends the result, if successful, to
// `OnAdAuctionEventRecordHeaderReceived()` on `url_loader_network_observer_`.
class AdAuctionEventRecordRequestHelper {
public:
// `url_loader_network_observer` must outlive this instance.
AdAuctionEventRecordRequestHelper(
mojom::AttributionReportingEligibility attribution_reporting_eligibility,
mojom::URLLoaderNetworkServiceObserver& url_loader_network_observer)
: attribution_reporting_eligibility_(attribution_reporting_eligibility),
url_loader_network_observer_(url_loader_network_observer) {}
// If `attribution_reporting_eligibility_` is one of the eligible values,
// parses the Ad-Auction-Record-Event header in `request`, sending the result
// to `OnAdAuctionEventRecordHeaderReceived()` on
// `url_loader_network_observer_`.
void HandleResponse(const net::URLRequest& request);
private:
mojom::AttributionReportingEligibility attribution_reporting_eligibility_ =
mojom::AttributionReportingEligibility::kUnset;
const raw_ref<mojom::URLLoaderNetworkServiceObserver>
url_loader_network_observer_;
};
} // namespace network
#endif // SERVICES_NETWORK_AD_AUCTION_EVENT_RECORD_REQUEST_HELPER_H_

@ -25,6 +25,7 @@ mojom("mojom_ad_auction") {
{
mojom = "network.mojom.AdAuctionEventRecord"
cpp = "::network::AdAuctionEventRecord"
move_only = true
},
]
traits_headers = [
@ -636,6 +637,7 @@ mojom("url_loader_base") {
public_deps = [
":cookies_mojom",
":mojom_ad_auction",
":mojom_attribution",
":mojom_content_security_policy",
":mojom_integrity_algorithm",

@ -7,6 +7,7 @@ module network.mojom;
import "mojo/public/mojom/base/string16.mojom";
import "mojo/public/mojom/base/time.mojom";
import "mojo/public/mojom/base/unguessable_token.mojom";
import "services/network/public/mojom/ad_auction.mojom";
import "services/network/public/mojom/cookie_partition_key.mojom";
import "services/network/public/mojom/ip_address.mojom";
import "services/network/public/mojom/ip_address_space.mojom";
@ -195,8 +196,16 @@ interface URLLoaderNetworkServiceObserver {
// triggered by response headers and Shared Storage calls from the same
// renderer process where this request originated.
OnSharedStorageHeaderReceived(url.mojom.Origin request_origin,
array<SharedStorageModifierMethodWithOptions> methods_with_options,
network.mojom.LockName? with_lock) => ();
array<SharedStorageModifierMethodWithOptions> methods_with_options,
network.mojom.LockName? with_lock) => ();
// Notifies the browser of the results of successful parsing of
// a Ad-Auction-Record-Event header. The browser stores information from
// `ad_auction_event_record` in the interest group database, for use in
// generateBid() browser signals during interest group auctions, providing
// bidders view and click signals.
OnAdAuctionEventRecordHeaderReceived(
AdAuctionEventRecord ad_auction_event_record);
// Used by the NetworkService to create a copy of this observer.
// (e.g. when creating an observer for URLLoader from URLLoaderFactory's

@ -87,6 +87,9 @@ void TestURLLoaderNetworkObserver::OnSharedStorageHeaderReceived(
std::move(callback).Run();
}
void TestURLLoaderNetworkObserver::OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) {}
void TestURLLoaderNetworkObserver::Clone(
mojo::PendingReceiver<URLLoaderNetworkServiceObserver> observer) {
receivers_.Add(this, std::move(observer));

@ -72,6 +72,8 @@ class TestURLLoaderNetworkObserver
methods_with_options,
const std::optional<std::string>& with_lock,
OnSharedStorageHeaderReceivedCallback callback) override;
void OnAdAuctionEventRecordHeaderReceived(
network::AdAuctionEventRecord event_record) override;
void Clone(
mojo::PendingReceiver<URLLoaderNetworkServiceObserver> observer) override;
void OnWebSocketConnectedToPrivateNetwork(

@ -717,6 +717,9 @@ URLLoader::URLLoader(
std::make_unique<SharedStorageRequestHelper>(
shared_storage_writable_eligible,
url_loader_network_observer_)),
ad_auction_event_record_request_helper_(
request.attribution_reporting_eligibility,
*url_loader_network_observer_),
has_fetch_streaming_upload_body_(HasFetchStreamingUploadBody(&request)),
accept_ch_frame_observer_(std::move(accept_ch_frame_observer)),
allow_cookies_from_browser_(
@ -2029,17 +2032,24 @@ void URLLoader::ProcessInboundSharedStorageInterceptorOnResponseStarted() {
void URLLoader::ProcessInboundAttributionInterceptorOnResponseStarted() {
if (!attribution_request_helper_) {
ProcessInboundSharedStorageInterceptorOnResponseStarted();
ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted();
return;
}
attribution_request_helper_->Finalize(
*response_,
base::BindOnce(
&URLLoader::ProcessInboundSharedStorageInterceptorOnResponseStarted,
&URLLoader::
ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted,
weak_ptr_factory_.GetWeakPtr()));
}
void URLLoader::
ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted() {
ad_auction_event_record_request_helper_.HandleResponse(*url_request_);
ProcessInboundSharedStorageInterceptorOnResponseStarted();
}
void URLLoader::OnResponseStarted(net::URLRequest* url_request, int net_error) {
DCHECK(url_request == url_request_.get());
has_received_response_ = true;

@ -36,6 +36,7 @@
#include "net/socket/socket_tag.h"
#include "net/traffic_annotation/network_traffic_annotation.h"
#include "net/url_request/url_request.h"
#include "services/network/ad_auction/event_record_request_helper.h"
#include "services/network/attribution/attribution_request_helper.h"
#include "services/network/keepalive_statistics_recorder.h"
#include "services/network/network_service.h"
@ -412,8 +413,8 @@ class COMPONENT_EXPORT(NETWORK_SERVICE) URLLoader
// Storage operations to call
// - If the request has not received the `kSharedStorageWriteHeader` response
// header, or if parsing fails to produce any valid operations, then
// immediately call `ContinueOnReceivedRedirect`
// - Otherwise, `ContinueOnReceivedRedirect` will be run asynchronously after
// immediately call `ContinueOnReceiveRedirect`
// - Otherwise, `ContinueOnReceiveRedirect` will be run asynchronously after
// forwarding the operations to `URLLoaderNetworkServiceObserver` to queue via
// Mojo
//
@ -448,7 +449,7 @@ class COMPONENT_EXPORT(NETWORK_SERVICE) URLLoader
//
// Start in `ProcessOutboundAttributionInterceptor`
// - If `attribution_request_helper_` is not defined, immediately
// calls`ScheduleStart`.
// calls `ScheduleStart`.
// - Otherwise:
// - Execute `AttributionRequestHelper::Begin`
// - On Begin's callback, calls `ScheduleStart`
@ -466,18 +467,44 @@ class COMPONENT_EXPORT(NETWORK_SERVICE) URLLoader
// Inbound control flow:
//
// Start in `ProcessInboundAttributionInterceptorOnResponseStarted`
// - If `attribution_request_helper_` is not defined, immediately
// calls`ProcessInboundSharedStorageInterceptorOnResponseStarted`.
// - If `attribution_request_helper_` is not defined,
// immediately calls
// `ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted`.
// - Otherwise:
// - Execute `AttributionRequestHelper::Finalize`
// - On Finalize's callback, calls
// `ProcessInboundSharedStorageInterceptorOnResponseStarted`
// `ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted`.
void ProcessOutboundAttributionInterceptor();
void ProcessInboundAttributionInterceptorOnReceivedRedirect(
const ::net::RedirectInfo& redirect_info,
mojom::URLResponseHeadPtr response);
void ProcessInboundAttributionInterceptorOnResponseStarted();
// All inbound responses will invoke
// `ad_auction_event_record_request_helper_.HandleResponse()`, which
// always returns immediately without blocking, and also exits early for
// ineligible responses.
//
// Outbound control flow:
//
// There are no outbound flow methods as the request headers are set by
// ComputeAttributionReportingHeaders() in the URLLoader() constructor.
//
// Redirection control flow:
//
// Redirection isn't handled yet. TODO(crbug.com/394108643): Support
// capturing headers on redirection responses.
//
// Inbound control flow:
//
// Start in
// `ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted()`
// - Execute
// `ad_auction_event_record_request_helper_::HandleResponse()`.
// - Afterwards, execute
// `ProcessInboundSharedStorageInterceptorOnResponseStarted()`.
void ProcessInboundAdAuctionEventRecordInterceptorOnResponseStarted();
// Continuation of `OnReceivedRedirect` after possibly asynchronously
// concluding the request's Attribution and/or Shared Storage operations.
void ContinueOnReceiveRedirect(const ::net::RedirectInfo& redirect_info,
@ -846,6 +873,11 @@ class COMPONENT_EXPORT(NETWORK_SERVICE) URLLoader
// (https://github.com/WICG/shared-storage#from-response-headers).
std::unique_ptr<SharedStorageRequestHelper> shared_storage_request_helper_;
// Request helper responsible for processing Ad Auction record event
// headers.
// (https://github.com/WICG/turtledove/pull/1279)
AdAuctionEventRecordRequestHelper ad_auction_event_record_request_helper_;
// Indicates |url_request_| is fetch upload request and that has streaming
// body.
const bool has_fetch_streaming_upload_body_;

@ -18727,6 +18727,25 @@
]
}
],
"ProtectedAudienceClickiness": [
{
"platforms": [
"android",
"chromeos",
"linux",
"mac",
"windows"
],
"experiments": [
{
"name": "Enabled",
"enable_features": [
"AdAuctionEventRegistration"
]
}
]
}
],
"ProtectedAudienceDealsSupport": [
{
"platforms": [

@ -960,6 +960,7 @@ bool CopyViewAndClickCountsProvidersFromIdlToMojo(
provider.Utf8().c_str(), input.name().Utf8().c_str()));
return false;
}
view_and_click_counts_providers.push_back(parsed_provider);
}
output.view_and_click_counts_providers =