Calculate L4S stats from the received congestion control feedbacks

Reland of https://webrtc-review.googlesource.com/c/src/+/420821

Bug: webrtc:437303401
Change-Id: I22063a3cad7cae3a8564524f519fee0033e5eef5
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/422962
Reviewed-by: Per Kjellander <perkj@webrtc.org>
Commit-Queue: Per Kjellander <perkj@webrtc.org>
Auto-Submit: Danil Chapovalov <danilchap@webrtc.org>
Commit-Queue: Danil Chapovalov <danilchap@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#46117}
diff --git a/call/BUILD.gn b/call/BUILD.gn
index 3e7d690..effafa2 100644
--- a/call/BUILD.gn
+++ b/call/BUILD.gn
@@ -193,6 +193,7 @@
     "../api/task_queue:pending_task_safety_flag",
     "../api/transport:bandwidth_estimation_settings",
     "../api/transport:bitrate_settings",
+    "../api/transport:ecn_marking",
     "../api/transport:goog_cc",
     "../api/transport:network_control",
     "../api/transport/rtp:dependency_descriptor",
@@ -214,6 +215,7 @@
     "../common_video/generic_frame_descriptor",
     "../logging:rtc_event_bwe",
     "../modules:module_fec_api",
+    "../modules/congestion_controller/rtp:congestion_controller_feedback_stats",
     "../modules/congestion_controller/rtp:control_handler",
     "../modules/congestion_controller/rtp:transport_feedback",
     "../modules/congestion_controller/scream:scream_network_controller",
@@ -233,6 +235,7 @@
     "../rtc_base:random",
     "../rtc_base:rate_limiter",
     "../rtc_base:safe_conversions",
+    "../rtc_base/containers:flat_map",
     "../rtc_base/experiments:field_trial_parser",
     "../rtc_base/network:sent_packet",
     "../rtc_base/synchronization:mutex",
@@ -500,6 +503,7 @@
         "rtp_bitrate_configurator_unittest.cc",
         "rtp_demuxer_unittest.cc",
         "rtp_payload_params_unittest.cc",
+        "rtp_transport_controller_send_unittest.cc",
         "rtp_video_sender_unittest.cc",
         "rtx_receive_stream_unittest.cc",
       ]
@@ -536,6 +540,7 @@
         "../api/test/network_emulation",
         "../api/test/video:function_video_factory",
         "../api/transport:bitrate_settings",
+        "../api/transport:ecn_marking",
         "../api/transport:network_control",
         "../api/transport/rtp:dependency_descriptor",
         "../api/units:data_rate",
@@ -556,6 +561,7 @@
         "../media:media_constants",
         "../modules/audio_device:mock_audio_device",
         "../modules/audio_processing:mocks",
+        "../modules/congestion_controller/rtp:congestion_controller_feedback_stats",
         "../modules/rtp_rtcp",
         "../modules/rtp_rtcp:mock_rtp_rtcp",
         "../modules/rtp_rtcp:rtp_rtcp_format",
@@ -574,6 +580,7 @@
         "../rtc_base:task_queue_for_test",
         "../rtc_base:threading",
         "../rtc_base:timeutils",
+        "../rtc_base/containers:flat_map",
         "../rtc_base/synchronization:mutex",
         "../test:audio_codec_mocks",
         "../test:create_test_environment",
diff --git a/call/rtp_transport_controller_send.cc b/call/rtp_transport_controller_send.cc
index 77ada73..880855b 100644
--- a/call/rtp_transport_controller_send.cc
+++ b/call/rtp_transport_controller_send.cc
@@ -31,6 +31,7 @@
 #include "api/task_queue/task_queue_base.h"
 #include "api/transport/bandwidth_estimation_settings.h"
 #include "api/transport/bitrate_settings.h"
+#include "api/transport/ecn_marking.h"
 #include "api/transport/goog_cc_factory.h"
 #include "api/transport/network_control.h"
 #include "api/transport/network_types.h"
@@ -44,6 +45,7 @@
 #include "call/rtp_video_sender.h"
 #include "call/rtp_video_sender_interface.h"
 #include "logging/rtc_event_log/events/rtc_event_route_change.h"
+#include "modules/congestion_controller/rtp/congestion_controller_feedback_stats.h"
 #include "modules/congestion_controller/rtp/control_handler.h"
 #include "modules/congestion_controller/scream/scream_network_controller.h"
 #include "modules/pacing/packet_router.h"
@@ -53,6 +55,7 @@
 #include "modules/rtp_rtcp/source/rtcp_packet/transport_feedback.h"
 #include "modules/rtp_rtcp/source/rtp_rtcp_interface.h"
 #include "rtc_base/checks.h"
+#include "rtc_base/containers/flat_map.h"
 #include "rtc_base/experiments/field_trial_parser.h"
 #include "rtc_base/logging.h"
 #include "rtc_base/network/sent_packet.h"
@@ -647,6 +650,12 @@
   return feedback_count_;
 }
 
+flat_map<uint32_t, ReceivedCongestionControlFeedbackStats>
+RtpTransportControllerSend::GetCongestionControlFeedbackStatsPerSsrc() const {
+  RTC_DCHECK_RUN_ON(&sequence_checker_);
+  return received_ccfb_stats_;
+}
+
 std::optional<int>
 RtpTransportControllerSend::ReceivedTransportCcFeedbackCount() const {
   RTC_DCHECK_RUN_ON(&sequence_checker_);
@@ -678,10 +687,60 @@
       transport_feedback_adapter_.ProcessCongestionControlFeedback(
           feedback, receive_time);
   if (feedback_msg) {
+    ComputeStatsFromCongestionControlFeedback(*feedback_msg);
     HandleTransportPacketsFeedback(*feedback_msg);
   }
 }
 
+void RtpTransportControllerSend::ComputeStatsFromCongestionControlFeedback(
+    const TransportPacketsFeedback& feedback) {
+  std::optional<uint32_t> last_ssrc;
+  ReceivedCongestionControlFeedbackStats* stats = nullptr;
+  for (const PacketResult& packet_info : feedback.packet_feedbacks) {
+    if (!packet_info.rtp_packet_info.has_value()) {
+      continue;
+    }
+
+    // Most of the time ssrc doesn't change across packets, so reuse last
+    // map lookup when ssrc is the same. Initially last_ssrc is nullopt,
+    // so first check would always trigger map lookup, thus `stats` would always
+    // be nonnull after this block.
+    if (uint32_t ssrc = packet_info.rtp_packet_info->ssrc; ssrc != last_ssrc) {
+      last_ssrc = ssrc;
+      stats = &received_ccfb_stats_[ssrc];
+    }
+
+    if (packet_info.reported_lost_for_the_first_time) {
+      RTC_DCHECK(!packet_info.IsReceived());
+      ++stats->num_packets_reported_as_lost;
+    }
+
+    if (packet_info.reported_recovered_for_the_first_time) {
+      RTC_DCHECK(packet_info.IsReceived());
+      ++stats->num_packets_reported_as_lost_but_recovered;
+    }
+
+    if (packet_info.IsReceived()) {
+      switch (packet_info.ecn) {
+        using enum EcnMarking;
+        case kEct1:
+          ++stats->num_packets_received_with_ect1;
+          break;
+        case kCe:
+          ++stats->num_packets_received_with_ce;
+          break;
+        case kNotEct:
+          if (packet_info.sent_with_ect1) {
+            ++stats->num_packets_with_bleached_ect1_marking;
+          }
+          break;
+        case kEct0:
+          break;
+      }
+    }
+  }
+}
+
 void RtpTransportControllerSend::HandleTransportPacketsFeedback(
     const TransportPacketsFeedback& feedback) {
   if (sending_packets_as_ect1_) {
@@ -869,4 +928,17 @@
   last_report_block_time_ = receive_time;
 }
 
+void RtpTransportControllerSend::NotifyBweOfSentPacketForTesting(
+    const RtpPacketToSend& rtp_packet) {
+  NotifyBweOfPacedSentPacket(rtp_packet, /*pacing_info=*/{});
+  PacketInfo packet_info;
+  packet_info.included_in_allocation = true;
+  packet_info.included_in_feedback =
+      rtp_packet.transport_sequence_number().has_value();
+  OnSentPacket(SentPacketInfo(
+      /*packet_id=*/rtp_packet.transport_sequence_number().value_or(-1),
+      /*send_time_ms=*/env_.clock().CurrentTime().ms(),
+      /*info=*/packet_info));
+}
+
 }  // namespace webrtc
diff --git a/call/rtp_transport_controller_send.h b/call/rtp_transport_controller_send.h
index 826fce6..e8a4db0 100644
--- a/call/rtp_transport_controller_send.h
+++ b/call/rtp_transport_controller_send.h
@@ -41,6 +41,7 @@
 #include "call/rtp_transport_config.h"
 #include "call/rtp_transport_controller_send_interface.h"
 #include "call/rtp_video_sender.h"
+#include "modules/congestion_controller/rtp/congestion_controller_feedback_stats.h"
 #include "modules/congestion_controller/rtp/control_handler.h"
 #include "modules/congestion_controller/rtp/transport_feedback_adapter.h"
 #include "modules/congestion_controller/rtp/transport_feedback_demuxer.h"
@@ -49,6 +50,7 @@
 #include "modules/rtp_rtcp/include/report_block_data.h"
 #include "modules/rtp_rtcp/include/rtp_rtcp_defines.h"
 #include "modules/rtp_rtcp/source/rtcp_packet/congestion_control_feedback.h"
+#include "rtc_base/containers/flat_map.h"
 #include "rtc_base/experiments/field_trial_parser.h"
 #include "rtc_base/network_route.h"
 #include "rtc_base/rate_limiter.h"
@@ -146,12 +148,19 @@
   void EnableCongestionControlFeedbackAccordingToRfc8888() override;
 
   std::optional<int> ReceivedCongestionControlFeedbackCount() const override;
+  flat_map<uint32_t, ReceivedCongestionControlFeedbackStats>
+  GetCongestionControlFeedbackStatsPerSsrc() const;
   std::optional<int> ReceivedTransportCcFeedbackCount() const override;
 
+  // Mimics callbacks for packets sent through this transport.
+  void NotifyBweOfSentPacketForTesting(const RtpPacketToSend& rtp_packet);
+
  private:
   void MaybeCreateControllers() RTC_RUN_ON(sequence_checker_);
   void HandleTransportPacketsFeedback(const TransportPacketsFeedback& feedback)
       RTC_RUN_ON(sequence_checker_);
+  void ComputeStatsFromCongestionControlFeedback(
+      const TransportPacketsFeedback& feedback) RTC_RUN_ON(sequence_checker_);
   void UpdateNetworkAvailability() RTC_RUN_ON(sequence_checker_);
   void UpdateInitialConstraints(TargetRateConstraints new_contraints)
       RTC_RUN_ON(sequence_checker_);
@@ -215,6 +224,8 @@
   };
   std::map<uint32_t, LossReport> last_report_blocks_
       RTC_GUARDED_BY(sequence_checker_);
+  flat_map<uint32_t, ReceivedCongestionControlFeedbackStats>
+      received_ccfb_stats_ RTC_GUARDED_BY(sequence_checker_);
   Timestamp last_report_block_time_ RTC_GUARDED_BY(sequence_checker_);
 
   NetworkControllerConfig initial_config_ RTC_GUARDED_BY(sequence_checker_);
diff --git a/call/rtp_transport_controller_send_unittest.cc b/call/rtp_transport_controller_send_unittest.cc
new file mode 100644
index 0000000..024e06d
--- /dev/null
+++ b/call/rtp_transport_controller_send_unittest.cc
@@ -0,0 +1,295 @@
+/*
+ *  Copyright (c) 2025 The WebRTC project authors. All Rights Reserved.
+ *
+ *  Use of this source code is governed by a BSD-style license
+ *  that can be found in the LICENSE file in the root of the source
+ *  tree. An additional intellectual property rights grant can be found
+ *  in the file PATENTS.  All contributing project authors may
+ *  be found in the AUTHORS file in the root of the source tree.
+ */
+
+#include "call/rtp_transport_controller_send.h"
+
+#include <cstdint>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include "api/transport/ecn_marking.h"
+#include "api/units/time_delta.h"
+#include "api/units/timestamp.h"
+#include "modules/congestion_controller/rtp/congestion_controller_feedback_stats.h"
+#include "modules/rtp_rtcp/include/rtp_rtcp_defines.h"
+#include "modules/rtp_rtcp/source/rtcp_packet/congestion_control_feedback.h"
+#include "rtc_base/containers/flat_map.h"
+#include "rtc_base/thread.h"
+#include "test/create_test_environment.h"
+#include "test/gmock.h"
+#include "test/gtest.h"
+
+namespace webrtc {
+namespace {
+
+using ::testing::AllOf;
+using ::testing::Eq;
+using ::testing::Field;
+using ::testing::IsEmpty;
+using ::testing::UnorderedElementsAre;
+
+constexpr uint32_t kSsrc = 0x554c;
+
+class PacketSender {
+ public:
+  explicit PacketSender(RtpTransportControllerSend& transport)
+      : transport_(transport) {}
+
+  struct SentPacketsOptions {
+    uint32_t ssrc = kSsrc;
+    uint16_t first_sequence_number = 1;
+    int num_packets = 1;
+    bool send_as_ect1 = true;
+  };
+  void SimulateSentPackets(SentPacketsOptions options) {
+    uint16_t sequence_number = options.first_sequence_number;
+    for (int i = 0; i < options.num_packets; ++i, ++sequence_number) {
+      RtpPacketToSend rtp_packet(nullptr);
+      rtp_packet.SetSsrc(options.ssrc);
+      rtp_packet.SetSequenceNumber(sequence_number);
+      rtp_packet.set_transport_sequence_number(++transport_sequence_number_);
+      rtp_packet.set_packet_type(RtpPacketMediaType::kVideo);
+      if (options.send_as_ect1) {
+        rtp_packet.set_send_as_ect1();
+      }
+      rtp_packet.SetPayloadSize(100);
+      transport_.NotifyBweOfSentPacketForTesting(rtp_packet);
+    }
+  }
+
+ private:
+  int64_t transport_sequence_number_ = 0;
+  RtpTransportControllerSend& transport_;
+};
+
+struct FeedbakPacketTemplate {
+  EcnMarking ecn = EcnMarking::kNotEct;
+  // If absent, the SSRC defaults to the previous SSRC.
+  std::optional<uint32_t> ssrc;
+  // If absent, the sequence number defaults to the previous sequence number
+  // plus one.
+  std::optional<uint16_t> sequence_number;
+  bool received = true;
+};
+rtcp::CongestionControlFeedback GenerateFeedback(
+    std::vector<FeedbakPacketTemplate> packets) {
+  std::vector<rtcp::CongestionControlFeedback::PacketInfo> packet_infos;
+  packet_infos.reserve(packets.size());
+  uint32_t ssrc = kSsrc;
+  uint16_t sequence_number = 1;
+  for (const FeedbakPacketTemplate& p : packets) {
+    ssrc = p.ssrc.value_or(ssrc);
+    sequence_number = p.sequence_number.value_or(sequence_number + 1);
+    packet_infos.push_back(
+        {.ssrc = ssrc,
+         .sequence_number = sequence_number,
+         .arrival_time_offset =
+             p.received ? TimeDelta::Millis(10) : TimeDelta::MinusInfinity(),
+         .ecn = p.ecn});
+  }
+  return rtcp::CongestionControlFeedback(std::move(packet_infos),
+                                         /*report_timestamp_compact_ntp=*/0);
+}
+
+TEST(RtpTransportControllerSendTest,
+     IgnoresFeedbackForReportedReceivedPacketThatWereNotSent) {
+  AutoThread main_thread;
+  RtpTransportControllerSend transport({.env = CreateTestEnvironment()});
+  transport.EnableCongestionControlFeedbackAccordingToRfc8888();
+  PacketSender sender(transport);
+  sender.SimulateSentPackets({.ssrc = 123,
+                              .first_sequence_number = 111,
+                              .num_packets = 10,
+                              .send_as_ect1 = true});
+  sender.SimulateSentPackets({.ssrc = 321,
+                              .first_sequence_number = 10'111,
+                              .num_packets = 10,
+                              .send_as_ect1 = true});
+
+  // Generate feedback for packets that weren't sent: reuse sequence number
+  // range from 1st batch, but SSRC from the 2nd batch to double check sequence
+  // numbers are checked per SSRC.
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback(
+          {{.ecn = EcnMarking::kEct1, .ssrc = 321, .sequence_number = 111},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kCe}}));
+  EXPECT_THAT(transport.GetCongestionControlFeedbackStatsPerSsrc(), IsEmpty());
+}
+
+TEST(RtpTransportControllerSendTest,
+     AccumulatesNumberOfReportedReceivedPacketsPerSsrcPerEcnMarkingType) {
+  constexpr uint32_t kSsrc1 = 1'000;
+  constexpr uint32_t kSsrc2 = 2'000;
+  AutoThread main_thread;
+  RtpTransportControllerSend transport({.env = CreateTestEnvironment()});
+  transport.EnableCongestionControlFeedbackAccordingToRfc8888();
+
+  PacketSender sender(transport);
+  sender.SimulateSentPackets(
+      {.ssrc = kSsrc1, .first_sequence_number = 1, .num_packets = 10});
+  sender.SimulateSentPackets(
+      {.ssrc = kSsrc2, .first_sequence_number = 101, .num_packets = 10});
+
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback(
+          {{.ecn = EcnMarking::kEct1, .ssrc = kSsrc1, .sequence_number = 1},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kCe},
+           {.ecn = EcnMarking::kEct1, .ssrc = kSsrc2, .sequence_number = 101},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kCe},
+           {.ecn = EcnMarking::kCe},
+           {.ecn = EcnMarking::kCe},
+           {.ecn = EcnMarking::kCe}}));
+
+  flat_map<uint32_t, ReceivedCongestionControlFeedbackStats> stats =
+      transport.GetCongestionControlFeedbackStatsPerSsrc();
+  EXPECT_EQ(stats[kSsrc1].num_packets_received_with_ect1, 2);
+  EXPECT_EQ(stats[kSsrc1].num_packets_received_with_ce, 1);
+  EXPECT_EQ(stats[kSsrc2].num_packets_received_with_ect1, 3);
+  EXPECT_EQ(stats[kSsrc2].num_packets_received_with_ce, 4);
+
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback(
+          {{.ecn = EcnMarking::kEct1, .ssrc = kSsrc1, .sequence_number = 5},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kEct1},
+           {.ecn = EcnMarking::kCe}}));
+
+  stats = transport.GetCongestionControlFeedbackStatsPerSsrc();
+  EXPECT_EQ(stats[kSsrc1].num_packets_received_with_ect1, 2 + 3);
+  EXPECT_EQ(stats[kSsrc1].num_packets_received_with_ce, 1 + 1);
+}
+
+TEST(RtpTransportControllerSendTest, CalculatesNumberOfBleachedPackets) {
+  AutoThread main_thread;
+  RtpTransportControllerSend transport({.env = CreateTestEnvironment()});
+  transport.EnableCongestionControlFeedbackAccordingToRfc8888();
+  PacketSender sender(transport);
+
+  // Packets send as ect1 and received without ect1 are the bleached packets.
+  sender.SimulateSentPackets(
+      {.first_sequence_number = 1, .num_packets = 10, .send_as_ect1 = true});
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.ecn = EcnMarking::kNotEct, .sequence_number = 1},
+                        {.ecn = EcnMarking::kNotEct},
+                        {.ecn = EcnMarking::kEct1},
+                        {.ecn = EcnMarking::kEct1},
+                        {.ecn = EcnMarking::kEct1},
+                        {.ecn = EcnMarking::kCe}}));
+  EXPECT_EQ(transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc]
+                .num_packets_with_bleached_ect1_marking,
+            2);
+
+  // Packets not send as ect1 do not add to number of bleached packets.
+  sender.SimulateSentPackets(
+      {.first_sequence_number = 11, .num_packets = 10, .send_as_ect1 = false});
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.ecn = EcnMarking::kNotEct, .sequence_number = 11},
+                        {.ecn = EcnMarking::kNotEct},
+                        {.ecn = EcnMarking::kNotEct}}));
+  EXPECT_EQ(transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc]
+                .num_packets_with_bleached_ect1_marking,
+            2 + 0);
+}
+
+TEST(RtpTransportControllerSendTest,
+     AccumulatesNumberOfReportedLostAndRecoveredPackets) {
+  AutoThread main_thread;
+  RtpTransportControllerSend transport({.env = CreateTestEnvironment()});
+  transport.EnableCongestionControlFeedbackAccordingToRfc8888();
+
+  PacketSender sender(transport);
+  sender.SimulateSentPackets({.first_sequence_number = 1, .num_packets = 30});
+
+  // Produce 1st report with 2 received and 3 lost packets.
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 1, .received = true},
+                        {.sequence_number = 2, .received = false},
+                        {.sequence_number = 3, .received = false},
+                        {.sequence_number = 4, .received = false},
+                        {.sequence_number = 5, .received = true}}));
+
+  ReceivedCongestionControlFeedbackStats stats =
+      transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc];
+  EXPECT_EQ(stats.num_packets_reported_as_lost, 3);
+  EXPECT_EQ(stats.num_packets_reported_as_lost_but_recovered, 0);
+
+  // Produce 2nd report with 1 packet recovered, 1 old packet reported still
+  // lost, and 2 new packets lost.
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 3, .received = true},
+                        {.sequence_number = 4, .received = false},
+                        {.sequence_number = 5, .received = true},
+                        {.sequence_number = 6, .received = false},
+                        {.sequence_number = 7, .received = false},
+                        {.sequence_number = 8, .received = true}}));
+  stats = transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc];
+  EXPECT_EQ(stats.num_packets_reported_as_lost, 3 + 2);
+  EXPECT_EQ(stats.num_packets_reported_as_lost_but_recovered, 1);
+
+  // Produce 3rd report with 2 more packets recovered.
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 6, .received = true},
+                        {.sequence_number = 7, .received = true},
+                        {.sequence_number = 8, .received = true}}));
+  stats = transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc];
+  EXPECT_EQ(stats.num_packets_reported_as_lost_but_recovered, 1 + 2);
+}
+
+TEST(RtpTransportControllerSendTest,
+     DoesNotCountGapsInSequenceNumberBetweenReportsAsLoss) {
+  AutoThread main_thread;
+  RtpTransportControllerSend transport({.env = CreateTestEnvironment()});
+  transport.EnableCongestionControlFeedbackAccordingToRfc8888();
+
+  PacketSender sender(transport);
+  sender.SimulateSentPackets({.first_sequence_number = 1, .num_packets = 30});
+
+  // Produce two report with a sequence number gap between them.
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 1}}));
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 5}}));
+
+  // It is unclear if packets 2-4 weren't received and thus were excluded from
+  // the feedback report, or report about these packets was itself lost.
+  // Such packets are not counted as loss.
+  EXPECT_EQ(transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc]
+                .num_packets_reported_as_lost,
+            0);
+
+  // Only count losses explicitly marked as such in a report to align with
+  // the metric definition "report has been sent with a zero R bit"
+  transport.OnCongestionControlFeedback(
+      /*receive_time=*/Timestamp::Seconds(123),
+      GenerateFeedback({{.sequence_number = 3, .received = true},
+                        {.sequence_number = 4, .received = false},
+                        {.sequence_number = 5, .received = true}}));
+  EXPECT_EQ(transport.GetCongestionControlFeedbackStatsPerSsrc()[kSsrc]
+                .num_packets_reported_as_lost,
+            1);
+}
+
+}  // namespace
+}  // namespace webrtc
diff --git a/modules/congestion_controller/rtp/congestion_controller_feedback_stats.h b/modules/congestion_controller/rtp/congestion_controller_feedback_stats.h
index b6a36c2..7c6b648 100644
--- a/modules/congestion_controller/rtp/congestion_controller_feedback_stats.h
+++ b/modules/congestion_controller/rtp/congestion_controller_feedback_stats.h
@@ -28,6 +28,31 @@
   int64_t num_packets_reported_recovered = 0;
 };
 
+// Helper struct to pass around stats computed from received RFC8888 reports.
+struct ReceivedCongestionControlFeedbackStats {
+  // Total number of packets reported as received with the "ECT(1)" marking.
+  // https://w3c.github.io/webrtc-stats/#dom-rtcreceivedrtpstreamstats-packetsreceivedwithect1
+  int64_t num_packets_received_with_ect1 = 0;
+
+  // Total number of packets reported as received with the "CE" marking.
+  // https://w3c.github.io/webrtc-stats/#dom-rtcreceivedrtpstreamstats-packetsreceivedwithce
+  int64_t num_packets_received_with_ce = 0;
+
+  // Total number of packets reported as lost in received feedback.
+  // https://w3c.github.io/webrtc-stats/#dom-rtcreceivedrtpstreamstats-packetsreportedaslost
+  int64_t num_packets_reported_as_lost = 0;
+
+  // Total number of packets reported first as lost in received feedback, but
+  // that were also reported as received in a later feedback.
+  // https://w3c.github.io/webrtc-stats/#dom-rtcreceivedrtpstreamstats-packetsreportedaslostbutrecovered
+  int64_t num_packets_reported_as_lost_but_recovered = 0;
+
+  // Total number of packets that were sent with ECT(1) markings, but were
+  // reported as received with "not-ECT" marking.
+  // https://w3c.github.io/webrtc-stats/#dom-rtcremoteinboundrtpstreamstats-packetswithbleachedect1marking
+  int64_t num_packets_with_bleached_ect1_marking = 0;
+};
+
 }  // namespace webrtc
 
 #endif  // MODULES_CONGESTION_CONTROLLER_RTP_CONGESTION_CONTROLLER_FEEDBACK_STATS_H_