Add `RTCRemoteOutboundRtpStreamStats` for audio streams

Changes:
- adding the `RTCRemoteOutboundRtpStreamStats` dictionary (see [1])
- collection of remote outbound stats (only for audio streams)
- adding `remote_id` to the inbound stats and set with the ID of the
  corresponding remote outbound stats only if the latter are available
- unit tests

[1] https://www.w3.org/TR/webrtc-stats/#dom-rtcremoteoutboundrtpstreamstats

Tested: verified from chrome://webrtc-internals during an appr.tc call

Bug: webrtc:12529
Change-Id: Ide91dc04a3c387ba439618a9c6b64a95994a1940
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/211042
Commit-Queue: Alessio Bazzica <alessiob@webrtc.org>
Reviewed-by: Björn Terelius <terelius@webrtc.org>
Reviewed-by: Sam Zackrisson <saza@webrtc.org>
Reviewed-by: Henrik Boström <hbos@webrtc.org>
Cr-Commit-Position: refs/heads/master@{#33545}
diff --git a/api/stats/rtcstats_objects.h b/api/stats/rtcstats_objects.h
index 3b92419..43f4be9 100644
--- a/api/stats/rtcstats_objects.h
+++ b/api/stats/rtcstats_objects.h
@@ -388,6 +388,7 @@
   RTCRTPStreamStats(std::string&& id, int64_t timestamp_us);
 };
 
+// https://www.w3.org/TR/webrtc-stats/#receivedrtpstats-dict*
 class RTC_EXPORT RTCReceivedRtpStreamStats : public RTCRTPStreamStats {
  public:
   WEBRTC_RTCSTATS_DECL();
@@ -410,6 +411,22 @@
   RTCReceivedRtpStreamStats(std::string&& id, int64_t timestamp_us);
 };
 
+// https://www.w3.org/TR/webrtc-stats/#sentrtpstats-dict*
+class RTC_EXPORT RTCSentRtpStreamStats : public RTCRTPStreamStats {
+ public:
+  WEBRTC_RTCSTATS_DECL();
+
+  RTCSentRtpStreamStats(const RTCSentRtpStreamStats& other);
+  ~RTCSentRtpStreamStats() override;
+
+  RTCStatsMember<uint32_t> packets_sent;
+  RTCStatsMember<uint64_t> bytes_sent;
+
+ protected:
+  RTCSentRtpStreamStats(const std::string&& id, int64_t timestamp_us);
+  RTCSentRtpStreamStats(std::string&& id, int64_t timestamp_us);
+};
+
 // https://w3c.github.io/webrtc-stats/#inboundrtpstats-dict*
 // TODO(hbos): Support the remote case |is_remote = true|.
 // https://bugs.webrtc.org/7065
@@ -423,6 +440,7 @@
   RTCInboundRTPStreamStats(const RTCInboundRTPStreamStats& other);
   ~RTCInboundRTPStreamStats() override;
 
+  RTCStatsMember<std::string> remote_id;
   RTCStatsMember<uint32_t> packets_received;
   RTCStatsMember<uint64_t> fec_packets_received;
   RTCStatsMember<uint64_t> fec_packets_discarded;
@@ -573,6 +591,22 @@
   RTCStatsMember<int32_t> round_trip_time_measurements;
 };
 
+// https://w3c.github.io/webrtc-stats/#remoteoutboundrtpstats-dict*
+class RTC_EXPORT RTCRemoteOutboundRtpStreamStats final
+    : public RTCSentRtpStreamStats {
+ public:
+  WEBRTC_RTCSTATS_DECL();
+
+  RTCRemoteOutboundRtpStreamStats(const std::string& id, int64_t timestamp_us);
+  RTCRemoteOutboundRtpStreamStats(std::string&& id, int64_t timestamp_us);
+  RTCRemoteOutboundRtpStreamStats(const RTCRemoteOutboundRtpStreamStats& other);
+  ~RTCRemoteOutboundRtpStreamStats() override;
+
+  RTCStatsMember<std::string> local_id;
+  RTCStatsMember<double> remote_timestamp;
+  RTCStatsMember<uint64_t> reports_sent;
+};
+
 // https://w3c.github.io/webrtc-stats/#dom-rtcmediasourcestats
 class RTC_EXPORT RTCMediaSourceStats : public RTCStats {
  public:
diff --git a/audio/audio_receive_stream.cc b/audio/audio_receive_stream.cc
index e037ddc..e99e39c 100644
--- a/audio/audio_receive_stream.cc
+++ b/audio/audio_receive_stream.cc
@@ -264,6 +264,14 @@
   stats.decoding_plc_cng = ds.decoded_plc_cng;
   stats.decoding_muted_output = ds.decoded_muted_output;
 
+  stats.last_sender_report_timestamp_ms =
+      call_stats.last_sender_report_timestamp_ms;
+  stats.last_sender_report_remote_timestamp_ms =
+      call_stats.last_sender_report_remote_timestamp_ms;
+  stats.sender_reports_packets_sent = call_stats.sender_reports_packets_sent;
+  stats.sender_reports_bytes_sent = call_stats.sender_reports_bytes_sent;
+  stats.sender_reports_reports_count = call_stats.sender_reports_reports_count;
+
   return stats;
 }
 
diff --git a/call/audio_receive_stream.h b/call/audio_receive_stream.h
index c53791e..6f74492 100644
--- a/call/audio_receive_stream.h
+++ b/call/audio_receive_stream.h
@@ -90,6 +90,13 @@
     int32_t total_interruption_duration_ms = 0;
     // https://w3c.github.io/webrtc-stats/#dom-rtcinboundrtpstreamstats-estimatedplayouttimestamp
     absl::optional<int64_t> estimated_playout_ntp_timestamp_ms;
+    // Remote outbound stats derived by the received RTCP sender reports.
+    // https://w3c.github.io/webrtc-stats/#remoteoutboundrtpstats-dict*
+    absl::optional<int64_t> last_sender_report_timestamp_ms;
+    absl::optional<int64_t> last_sender_report_remote_timestamp_ms;
+    uint32_t sender_reports_packets_sent = 0;
+    uint64_t sender_reports_bytes_sent = 0;
+    uint64_t sender_reports_reports_count = 0;
   };
 
   struct Config {
diff --git a/media/base/media_channel.h b/media/base/media_channel.h
index c964ce1..9b0ead1 100644
--- a/media/base/media_channel.h
+++ b/media/base/media_channel.h
@@ -536,6 +536,13 @@
   // longer than 150 ms).
   int32_t interruption_count = 0;
   int32_t total_interruption_duration_ms = 0;
+  // Remote outbound stats derived by the received RTCP sender reports.
+  // https://w3c.github.io/webrtc-stats/#remoteoutboundrtpstats-dict*
+  absl::optional<int64_t> last_sender_report_timestamp_ms;
+  absl::optional<int64_t> last_sender_report_remote_timestamp_ms;
+  uint32_t sender_reports_packets_sent = 0;
+  uint64_t sender_reports_bytes_sent = 0;
+  uint64_t sender_reports_reports_count = 0;
 };
 
 struct VideoSenderInfo : public MediaSenderInfo {
diff --git a/media/engine/webrtc_voice_engine.cc b/media/engine/webrtc_voice_engine.cc
index a2e6c17..f0ea10d 100644
--- a/media/engine/webrtc_voice_engine.cc
+++ b/media/engine/webrtc_voice_engine.cc
@@ -2461,6 +2461,13 @@
         stats.relative_packet_arrival_delay_seconds;
     rinfo.interruption_count = stats.interruption_count;
     rinfo.total_interruption_duration_ms = stats.total_interruption_duration_ms;
+    rinfo.last_sender_report_timestamp_ms =
+        stats.last_sender_report_timestamp_ms;
+    rinfo.last_sender_report_remote_timestamp_ms =
+        stats.last_sender_report_remote_timestamp_ms;
+    rinfo.sender_reports_packets_sent = stats.sender_reports_packets_sent;
+    rinfo.sender_reports_bytes_sent = stats.sender_reports_bytes_sent;
+    rinfo.sender_reports_reports_count = stats.sender_reports_reports_count;
 
     info->receivers.push_back(rinfo);
   }
diff --git a/pc/rtc_stats_collector.cc b/pc/rtc_stats_collector.cc
index c14f414..36ee542 100644
--- a/pc/rtc_stats_collector.cc
+++ b/pc/rtc_stats_collector.cc
@@ -109,17 +109,23 @@
   return sb.str();
 }
 
-std::string RTCInboundRTPStreamStatsIDFromSSRC(bool audio, uint32_t ssrc) {
+std::string RTCInboundRTPStreamStatsIDFromSSRC(cricket::MediaType media_type,
+                                               uint32_t ssrc) {
   char buf[1024];
   rtc::SimpleStringBuilder sb(buf);
-  sb << "RTCInboundRTP" << (audio ? "Audio" : "Video") << "Stream_" << ssrc;
+  sb << "RTCInboundRTP"
+     << (media_type == cricket::MEDIA_TYPE_AUDIO ? "Audio" : "Video")
+     << "Stream_" << ssrc;
   return sb.str();
 }
 
-std::string RTCOutboundRTPStreamStatsIDFromSSRC(bool audio, uint32_t ssrc) {
+std::string RTCOutboundRTPStreamStatsIDFromSSRC(cricket::MediaType media_type,
+                                                uint32_t ssrc) {
   char buf[1024];
   rtc::SimpleStringBuilder sb(buf);
-  sb << "RTCOutboundRTP" << (audio ? "Audio" : "Video") << "Stream_" << ssrc;
+  sb << "RTCOutboundRTP"
+     << (media_type == cricket::MEDIA_TYPE_AUDIO ? "Audio" : "Video")
+     << "Stream_" << ssrc;
   return sb.str();
 }
 
@@ -134,6 +140,17 @@
   return sb.str();
 }
 
+std::string RTCRemoteOutboundRTPStreamStatsIDFromSSRC(
+    cricket::MediaType media_type,
+    uint32_t source_ssrc) {
+  char buf[1024];
+  rtc::SimpleStringBuilder sb(buf);
+  sb << "RTCRemoteOutboundRTP"
+     << (media_type == cricket::MEDIA_TYPE_AUDIO ? "Audio" : "Video")
+     << "Stream_" << source_ssrc;
+  return sb.str();
+}
+
 std::string RTCMediaSourceStatsIDFromKindAndAttachment(
     cricket::MediaType media_type,
     int attachment_id) {
@@ -309,17 +326,21 @@
       static_cast<int32_t>(media_receiver_info.packets_lost);
 }
 
-void SetInboundRTPStreamStatsFromVoiceReceiverInfo(
-    const std::string& mid,
+std::unique_ptr<RTCInboundRTPStreamStats> CreateInboundAudioStreamStats(
     const cricket::VoiceReceiverInfo& voice_receiver_info,
-    RTCInboundRTPStreamStats* inbound_audio) {
+    const std::string& mid,
+    int64_t timestamp_us) {
+  auto inbound_audio = std::make_unique<RTCInboundRTPStreamStats>(
+      /*id=*/RTCInboundRTPStreamStatsIDFromSSRC(cricket::MEDIA_TYPE_AUDIO,
+                                                voice_receiver_info.ssrc()),
+      timestamp_us);
   SetInboundRTPStreamStatsFromMediaReceiverInfo(voice_receiver_info,
-                                                inbound_audio);
+                                                inbound_audio.get());
   inbound_audio->media_type = "audio";
   inbound_audio->kind = "audio";
   if (voice_receiver_info.codec_payload_type) {
     inbound_audio->codec_id = RTCCodecStatsIDFromMidDirectionAndPayload(
-        mid, true, *voice_receiver_info.codec_payload_type);
+        mid, /*inbound=*/true, *voice_receiver_info.codec_payload_type);
   }
   inbound_audio->jitter = static_cast<double>(voice_receiver_info.jitter_ms) /
                           rtc::kNumMillisecsPerSec;
@@ -358,6 +379,51 @@
       voice_receiver_info.fec_packets_received;
   inbound_audio->fec_packets_discarded =
       voice_receiver_info.fec_packets_discarded;
+  return inbound_audio;
+}
+
+std::unique_ptr<RTCRemoteOutboundRtpStreamStats>
+CreateRemoteOutboundAudioStreamStats(
+    const cricket::VoiceReceiverInfo& voice_receiver_info,
+    const std::string& mid,
+    const std::string& inbound_audio_id,
+    const std::string& transport_id) {
+  if (!voice_receiver_info.last_sender_report_timestamp_ms.has_value()) {
+    // Cannot create `RTCRemoteOutboundRtpStreamStats` when the RTCP SR arrival
+    // timestamp is not available - i.e., until the first sender report is
+    // received.
+    return nullptr;
+  }
+  RTC_DCHECK_GT(voice_receiver_info.sender_reports_reports_count, 0);
+
+  // Create.
+  auto stats = std::make_unique<RTCRemoteOutboundRtpStreamStats>(
+      /*id=*/RTCRemoteOutboundRTPStreamStatsIDFromSSRC(
+          cricket::MEDIA_TYPE_AUDIO, voice_receiver_info.ssrc()),
+      /*timestamp_us=*/rtc::kNumMicrosecsPerMillisec *
+          voice_receiver_info.last_sender_report_timestamp_ms.value());
+
+  // Populate.
+  // - RTCRtpStreamStats.
+  stats->ssrc = voice_receiver_info.ssrc();
+  stats->kind = "audio";
+  stats->transport_id = transport_id;
+  stats->codec_id = RTCCodecStatsIDFromMidDirectionAndPayload(
+      mid,
+      /*inbound=*/true,  // Remote-outbound same as local-inbound.
+      *voice_receiver_info.codec_payload_type);
+  // - RTCSentRtpStreamStats.
+  stats->packets_sent = voice_receiver_info.sender_reports_packets_sent;
+  stats->bytes_sent = voice_receiver_info.sender_reports_bytes_sent;
+  // - RTCRemoteOutboundRtpStreamStats.
+  stats->local_id = inbound_audio_id;
+  RTC_DCHECK(
+      voice_receiver_info.last_sender_report_remote_timestamp_ms.has_value());
+  stats->remote_timestamp = static_cast<double>(
+      voice_receiver_info.last_sender_report_remote_timestamp_ms.value());
+  stats->reports_sent = voice_receiver_info.sender_reports_reports_count;
+
+  return stats;
 }
 
 void SetInboundRTPStreamStatsFromVideoReceiverInfo(
@@ -370,7 +436,7 @@
   inbound_video->kind = "video";
   if (video_receiver_info.codec_payload_type) {
     inbound_video->codec_id = RTCCodecStatsIDFromMidDirectionAndPayload(
-        mid, true, *video_receiver_info.codec_payload_type);
+        mid, /*inbound=*/true, *video_receiver_info.codec_payload_type);
   }
   inbound_video->jitter = static_cast<double>(video_receiver_info.jitter_ms) /
                           rtc::kNumMillisecsPerSec;
@@ -454,7 +520,7 @@
   outbound_audio->kind = "audio";
   if (voice_sender_info.codec_payload_type) {
     outbound_audio->codec_id = RTCCodecStatsIDFromMidDirectionAndPayload(
-        mid, false, *voice_sender_info.codec_payload_type);
+        mid, /*inbound=*/false, *voice_sender_info.codec_payload_type);
   }
   // |fir_count|, |pli_count| and |sli_count| are only valid for video and are
   // purposefully left undefined for audio.
@@ -470,7 +536,7 @@
   outbound_video->kind = "video";
   if (video_sender_info.codec_payload_type) {
     outbound_video->codec_id = RTCCodecStatsIDFromMidDirectionAndPayload(
-        mid, false, *video_sender_info.codec_payload_type);
+        mid, /*inbound=*/false, *video_sender_info.codec_payload_type);
   }
   outbound_video->fir_count =
       static_cast<uint32_t>(video_sender_info.firs_rcvd);
@@ -550,8 +616,8 @@
   remote_inbound->round_trip_time_measurements =
       report_block_data.num_rtts();
 
-  std::string local_id = RTCOutboundRTPStreamStatsIDFromSSRC(
-      media_type == cricket::MEDIA_TYPE_AUDIO, report_block.source_ssrc);
+  std::string local_id =
+      RTCOutboundRTPStreamStatsIDFromSSRC(media_type, report_block.source_ssrc);
   // Look up local stat from |outbound_rtps| where the pointers are non-const.
   auto local_id_it = outbound_rtps.find(local_id);
   if (local_id_it != outbound_rtps.end()) {
@@ -1678,16 +1744,16 @@
   std::string mid = *stats.mid;
   std::string transport_id = RTCTransportStatsIDFromTransportChannel(
       *stats.transport_name, cricket::ICE_CANDIDATE_COMPONENT_RTP);
-  // Inbound
+  // Inbound and remote-outbound.
+  // The remote-outbound stats are based on RTCP sender reports sent from the
+  // remote endpoint providing metrics about the remote outbound streams.
   for (const cricket::VoiceReceiverInfo& voice_receiver_info :
        track_media_info_map.voice_media_info()->receivers) {
     if (!voice_receiver_info.connected())
       continue;
-    auto inbound_audio = std::make_unique<RTCInboundRTPStreamStats>(
-        RTCInboundRTPStreamStatsIDFromSSRC(true, voice_receiver_info.ssrc()),
-        timestamp_us);
-    SetInboundRTPStreamStatsFromVoiceReceiverInfo(mid, voice_receiver_info,
-                                                  inbound_audio.get());
+    // Inbound.
+    auto inbound_audio =
+        CreateInboundAudioStreamStats(voice_receiver_info, mid, timestamp_us);
     // TODO(hta): This lookup should look for the sender, not the track.
     rtc::scoped_refptr<AudioTrackInterface> audio_track =
         track_media_info_map.GetAudioTrack(voice_receiver_info);
@@ -1698,16 +1764,27 @@
               track_media_info_map.GetAttachmentIdByTrack(audio_track).value());
     }
     inbound_audio->transport_id = transport_id;
+    // Remote-outbound.
+    auto remote_outbound_audio = CreateRemoteOutboundAudioStreamStats(
+        voice_receiver_info, mid, inbound_audio->id(), transport_id);
+    // Add stats.
+    if (remote_outbound_audio) {
+      // When the remote outbound stats are available, the remote ID for the
+      // local inbound stats is set.
+      inbound_audio->remote_id = remote_outbound_audio->id();
+      report->AddStats(std::move(remote_outbound_audio));
+    }
     report->AddStats(std::move(inbound_audio));
   }
-  // Outbound
+  // Outbound.
   std::map<std::string, RTCOutboundRTPStreamStats*> audio_outbound_rtps;
   for (const cricket::VoiceSenderInfo& voice_sender_info :
        track_media_info_map.voice_media_info()->senders) {
     if (!voice_sender_info.connected())
       continue;
     auto outbound_audio = std::make_unique<RTCOutboundRTPStreamStats>(
-        RTCOutboundRTPStreamStatsIDFromSSRC(true, voice_sender_info.ssrc()),
+        RTCOutboundRTPStreamStatsIDFromSSRC(cricket::MEDIA_TYPE_AUDIO,
+                                            voice_sender_info.ssrc()),
         timestamp_us);
     SetOutboundRTPStreamStatsFromVoiceSenderInfo(mid, voice_sender_info,
                                                  outbound_audio.get());
@@ -1728,7 +1805,7 @@
         std::make_pair(outbound_audio->id(), outbound_audio.get()));
     report->AddStats(std::move(outbound_audio));
   }
-  // Remote-inbound
+  // Remote-inbound.
   // These are Report Block-based, information sent from the remote endpoint,
   // providing metrics about our Outbound streams. We take advantage of the fact
   // that RTCOutboundRtpStreamStats, RTCCodecStats and RTCTransport have already
@@ -1765,7 +1842,8 @@
     if (!video_receiver_info.connected())
       continue;
     auto inbound_video = std::make_unique<RTCInboundRTPStreamStats>(
-        RTCInboundRTPStreamStatsIDFromSSRC(false, video_receiver_info.ssrc()),
+        RTCInboundRTPStreamStatsIDFromSSRC(cricket::MEDIA_TYPE_VIDEO,
+                                           video_receiver_info.ssrc()),
         timestamp_us);
     SetInboundRTPStreamStatsFromVideoReceiverInfo(mid, video_receiver_info,
                                                   inbound_video.get());
@@ -1779,6 +1857,7 @@
     }
     inbound_video->transport_id = transport_id;
     report->AddStats(std::move(inbound_video));
+    // TODO(crbug.com/webrtc/12529): Add remote-outbound stats.
   }
   // Outbound
   std::map<std::string, RTCOutboundRTPStreamStats*> video_outbound_rtps;
@@ -1787,7 +1866,8 @@
     if (!video_sender_info.connected())
       continue;
     auto outbound_video = std::make_unique<RTCOutboundRTPStreamStats>(
-        RTCOutboundRTPStreamStatsIDFromSSRC(false, video_sender_info.ssrc()),
+        RTCOutboundRTPStreamStatsIDFromSSRC(cricket::MEDIA_TYPE_VIDEO,
+                                            video_sender_info.ssrc()),
         timestamp_us);
     SetOutboundRTPStreamStatsFromVideoSenderInfo(mid, video_sender_info,
                                                  outbound_video.get());
diff --git a/pc/rtc_stats_collector_unittest.cc b/pc/rtc_stats_collector_unittest.cc
index 35ff48c..897226d 100644
--- a/pc/rtc_stats_collector_unittest.cc
+++ b/pc/rtc_stats_collector_unittest.cc
@@ -119,6 +119,14 @@
 
 const int64_t kGetStatsReportTimeoutMs = 1000;
 
+// Fake data used by `SetupExampleStatsVoiceGraph()` to fill in remote outbound
+// stats.
+constexpr int64_t kRemoteOutboundStatsTimestampMs = 123;
+constexpr int64_t kRemoteOutboundStatsRemoteTimestampMs = 456;
+constexpr uint32_t kRemoteOutboundStatsPacketsSent = 7u;
+constexpr uint64_t kRemoteOutboundStatsBytesSent = 8u;
+constexpr uint64_t kRemoteOutboundStatsReportsCount = 9u;
+
 struct CertificateInfo {
   rtc::scoped_refptr<rtc::RTCCertificate> certificate;
   std::vector<std::string> ders;
@@ -575,6 +583,11 @@
     EXPECT_TRUE_WAIT(callback->report(), kGetStatsReportTimeoutMs);
     int64_t after = rtc::TimeUTCMicros();
     for (const RTCStats& stats : *callback->report()) {
+      if (stats.type() == RTCRemoteInboundRtpStreamStats::kType ||
+          stats.type() == RTCRemoteOutboundRtpStreamStats::kType) {
+        // Ignore remote timestamps.
+        continue;
+      }
       EXPECT_LE(stats.timestamp_us(), after);
     }
     return callback->report();
@@ -619,6 +632,7 @@
     std::string recv_codec_id;
     std::string outbound_rtp_id;
     std::string inbound_rtp_id;
+    std::string remote_outbound_rtp_id;
     std::string transport_id;
     std::string sender_track_id;
     std::string receiver_track_id;
@@ -627,9 +641,9 @@
     std::string media_source_id;
   };
 
-  // Sets up the example stats graph (see ASCII art below) used for testing the
-  // stats selection algorithm,
-  // https://w3c.github.io/webrtc-pc/#dfn-stats-selection-algorithm.
+  // Sets up the example stats graph (see ASCII art below) for a video only
+  // call. The graph is used for testing the stats selection algorithm (see
+  // https://w3c.github.io/webrtc-pc/#dfn-stats-selection-algorithm).
   // These tests test the integration of the stats traversal algorithm inside of
   // RTCStatsCollector. See rtcstatstraveral_unittest.cc for more stats
   // traversal tests.
@@ -731,6 +745,125 @@
     return graph;
   }
 
+  // Sets up an example stats graph (see ASCII art below) for an audio only call
+  // and checks that the expected stats are generated.
+  ExampleStatsGraph SetupExampleStatsVoiceGraph(
+      bool add_remote_outbound_stats) {
+    constexpr uint32_t kLocalSsrc = 3;
+    constexpr uint32_t kRemoteSsrc = 4;
+    ExampleStatsGraph graph;
+
+    // codec (send)
+    graph.send_codec_id = "RTCCodec_VoiceMid_Outbound_1";
+    cricket::VoiceMediaInfo media_info;
+    RtpCodecParameters send_codec;
+    send_codec.payload_type = 1;
+    send_codec.clock_rate = 0;
+    media_info.send_codecs.insert(
+        std::make_pair(send_codec.payload_type, send_codec));
+    // codec (recv)
+    graph.recv_codec_id = "RTCCodec_VoiceMid_Inbound_2";
+    RtpCodecParameters recv_codec;
+    recv_codec.payload_type = 2;
+    recv_codec.clock_rate = 0;
+    media_info.receive_codecs.insert(
+        std::make_pair(recv_codec.payload_type, recv_codec));
+    // outbound-rtp
+    graph.outbound_rtp_id = "RTCOutboundRTPAudioStream_3";
+    media_info.senders.push_back(cricket::VoiceSenderInfo());
+    media_info.senders[0].local_stats.push_back(cricket::SsrcSenderInfo());
+    media_info.senders[0].local_stats[0].ssrc = kLocalSsrc;
+    media_info.senders[0].codec_payload_type = send_codec.payload_type;
+    // inbound-rtp
+    graph.inbound_rtp_id = "RTCInboundRTPAudioStream_4";
+    media_info.receivers.push_back(cricket::VoiceReceiverInfo());
+    media_info.receivers[0].local_stats.push_back(cricket::SsrcReceiverInfo());
+    media_info.receivers[0].local_stats[0].ssrc = kRemoteSsrc;
+    media_info.receivers[0].codec_payload_type = recv_codec.payload_type;
+    // remote-outbound-rtp
+    if (add_remote_outbound_stats) {
+      graph.remote_outbound_rtp_id = "RTCRemoteOutboundRTPAudioStream_4";
+      media_info.receivers[0].last_sender_report_timestamp_ms =
+          kRemoteOutboundStatsTimestampMs;
+      media_info.receivers[0].last_sender_report_remote_timestamp_ms =
+          kRemoteOutboundStatsRemoteTimestampMs;
+      media_info.receivers[0].sender_reports_packets_sent =
+          kRemoteOutboundStatsPacketsSent;
+      media_info.receivers[0].sender_reports_bytes_sent =
+          kRemoteOutboundStatsBytesSent;
+      media_info.receivers[0].sender_reports_reports_count =
+          kRemoteOutboundStatsReportsCount;
+    }
+
+    // transport
+    graph.transport_id = "RTCTransport_TransportName_1";
+    auto* video_media_channel =
+        pc_->AddVoiceChannel("VoiceMid", "TransportName");
+    video_media_channel->SetStats(media_info);
+    // track (sender)
+    graph.sender = stats_->SetupLocalTrackAndSender(
+        cricket::MEDIA_TYPE_AUDIO, "LocalAudioTrackID", kLocalSsrc, false, 50);
+    graph.sender_track_id = "RTCMediaStreamTrack_sender_" +
+                            rtc::ToString(graph.sender->AttachmentId());
+    // track (receiver) and stream (remote stream)
+    graph.receiver = stats_->SetupRemoteTrackAndReceiver(
+        cricket::MEDIA_TYPE_AUDIO, "RemoteAudioTrackID", "RemoteStreamId",
+        kRemoteSsrc);
+    graph.receiver_track_id = "RTCMediaStreamTrack_receiver_" +
+                              rtc::ToString(graph.receiver->AttachmentId());
+    graph.remote_stream_id = "RTCMediaStream_RemoteStreamId";
+    // peer-connection
+    graph.peer_connection_id = "RTCPeerConnection";
+    // media-source (kind: video)
+    graph.media_source_id =
+        "RTCAudioSource_" + rtc::ToString(graph.sender->AttachmentId());
+
+    // Expected stats graph:
+    //
+    //  +--- track (sender)      stream (remote stream) ---> track (receiver)
+    //  |             ^                                        ^
+    //  |             |                                        |
+    //  | +--------- outbound-rtp   inbound-rtp ---------------+
+    //  | |           |        |     |       |
+    //  | |           v        v     v       v
+    //  | |  codec (send)     transport     codec (recv)     peer-connection
+    //  v v
+    //  media-source
+
+    // Verify the stats graph is set up correctly.
+    graph.full_report = stats_->GetStatsReport();
+    EXPECT_EQ(graph.full_report->size(), add_remote_outbound_stats ? 11u : 10u);
+    EXPECT_TRUE(graph.full_report->Get(graph.send_codec_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.recv_codec_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.outbound_rtp_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.inbound_rtp_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.transport_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.sender_track_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.receiver_track_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.remote_stream_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.peer_connection_id));
+    EXPECT_TRUE(graph.full_report->Get(graph.media_source_id));
+    // `graph.remote_outbound_rtp_id` is omitted on purpose so that expectations
+    // can be added by the caller depending on what value it sets for the
+    // `add_remote_outbound_stats` argument.
+    const auto& sender_track = graph.full_report->Get(graph.sender_track_id)
+                                   ->cast_to<RTCMediaStreamTrackStats>();
+    EXPECT_EQ(*sender_track.media_source_id, graph.media_source_id);
+    const auto& outbound_rtp = graph.full_report->Get(graph.outbound_rtp_id)
+                                   ->cast_to<RTCOutboundRTPStreamStats>();
+    EXPECT_EQ(*outbound_rtp.media_source_id, graph.media_source_id);
+    EXPECT_EQ(*outbound_rtp.codec_id, graph.send_codec_id);
+    EXPECT_EQ(*outbound_rtp.track_id, graph.sender_track_id);
+    EXPECT_EQ(*outbound_rtp.transport_id, graph.transport_id);
+    const auto& inbound_rtp = graph.full_report->Get(graph.inbound_rtp_id)
+                                  ->cast_to<RTCInboundRTPStreamStats>();
+    EXPECT_EQ(*inbound_rtp.codec_id, graph.recv_codec_id);
+    EXPECT_EQ(*inbound_rtp.track_id, graph.receiver_track_id);
+    EXPECT_EQ(*inbound_rtp.transport_id, graph.transport_id);
+
+    return graph;
+  }
+
  protected:
   rtc::ScopedFakeClock fake_clock_;
   rtc::scoped_refptr<FakePeerConnectionForStats> pc_;
@@ -2872,6 +3005,43 @@
                          ::testing::Values(cricket::MEDIA_TYPE_AUDIO,    // "/0"
                                            cricket::MEDIA_TYPE_VIDEO));  // "/1"
 
+// Checks that no remote outbound stats are collected if not available in
+// `VoiceMediaInfo`.
+TEST_F(RTCStatsCollectorTest,
+       RTCRemoteOutboundRtpAudioStreamStatsNotCollected) {
+  ExampleStatsGraph graph =
+      SetupExampleStatsVoiceGraph(/*add_remote_outbound_stats=*/false);
+  EXPECT_FALSE(graph.full_report->Get(graph.remote_outbound_rtp_id));
+  // Also check that no other remote outbound report is created (in case the
+  // expected ID is incorrect).
+  rtc::scoped_refptr<const RTCStatsReport> report = stats_->GetStatsReport();
+  ASSERT_NE(report->begin(), report->end())
+      << "No reports have been generated.";
+  for (const auto& stats : *report) {
+    SCOPED_TRACE(stats.id());
+    EXPECT_NE(stats.type(), RTCRemoteOutboundRtpStreamStats::kType);
+  }
+}
+
+// Checks that the remote outbound stats are collected when available in
+// `VoiceMediaInfo`.
+TEST_F(RTCStatsCollectorTest, RTCRemoteOutboundRtpAudioStreamStatsCollected) {
+  ExampleStatsGraph graph =
+      SetupExampleStatsVoiceGraph(/*add_remote_outbound_stats=*/true);
+  ASSERT_TRUE(graph.full_report->Get(graph.remote_outbound_rtp_id));
+  const auto& remote_outbound_rtp =
+      graph.full_report->Get(graph.remote_outbound_rtp_id)
+          ->cast_to<RTCRemoteOutboundRtpStreamStats>();
+  EXPECT_EQ(remote_outbound_rtp.timestamp_us(),
+            kRemoteOutboundStatsTimestampMs * rtc::kNumMicrosecsPerMillisec);
+  EXPECT_FLOAT_EQ(*remote_outbound_rtp.remote_timestamp,
+                  static_cast<double>(kRemoteOutboundStatsRemoteTimestampMs));
+  EXPECT_EQ(*remote_outbound_rtp.packets_sent, kRemoteOutboundStatsPacketsSent);
+  EXPECT_EQ(*remote_outbound_rtp.bytes_sent, kRemoteOutboundStatsBytesSent);
+  EXPECT_EQ(*remote_outbound_rtp.reports_sent,
+            kRemoteOutboundStatsReportsCount);
+}
+
 TEST_F(RTCStatsCollectorTest,
        RTCVideoSourceStatsNotCollectedForSenderWithoutTrack) {
   const uint32_t kSsrc = 4;
diff --git a/pc/rtc_stats_integrationtest.cc b/pc/rtc_stats_integrationtest.cc
index a285555..8b12c67 100644
--- a/pc/rtc_stats_integrationtest.cc
+++ b/pc/rtc_stats_integrationtest.cc
@@ -399,6 +399,9 @@
       } else if (stats.type() == RTCRemoteInboundRtpStreamStats::kType) {
         verify_successful &= VerifyRTCRemoteInboundRtpStreamStats(
             stats.cast_to<RTCRemoteInboundRtpStreamStats>());
+      } else if (stats.type() == RTCRemoteOutboundRtpStreamStats::kType) {
+        verify_successful &= VerifyRTCRemoteOutboundRTPStreamStats(
+            stats.cast_to<RTCRemoteOutboundRtpStreamStats>());
       } else if (stats.type() == RTCAudioSourceStats::kType) {
         // RTCAudioSourceStats::kType and RTCVideoSourceStats::kType both have
         // the value "media-source", but they are distinguishable with pointer
@@ -769,29 +772,38 @@
   }
 
   void VerifyRTCRTPStreamStats(const RTCRTPStreamStats& stream,
-                               RTCStatsVerifier* verifier) {
-    verifier->TestMemberIsDefined(stream.ssrc);
-    verifier->TestMemberIsDefined(stream.kind);
+                               RTCStatsVerifier& verifier) {
+    verifier.TestMemberIsDefined(stream.ssrc);
+    verifier.TestMemberIsDefined(stream.kind);
     // Some legacy metrics are only defined for some of the RTP types in the
     // hierarcy.
     if (stream.type() == RTCInboundRTPStreamStats::kType ||
         stream.type() == RTCOutboundRTPStreamStats::kType) {
-      verifier->TestMemberIsDefined(stream.media_type);
-      verifier->TestMemberIsIDReference(stream.track_id,
-                                        RTCMediaStreamTrackStats::kType);
+      verifier.TestMemberIsDefined(stream.media_type);
+      verifier.TestMemberIsIDReference(stream.track_id,
+                                       RTCMediaStreamTrackStats::kType);
     } else {
-      verifier->TestMemberIsUndefined(stream.media_type);
-      verifier->TestMemberIsUndefined(stream.track_id);
+      verifier.TestMemberIsUndefined(stream.media_type);
+      verifier.TestMemberIsUndefined(stream.track_id);
     }
-    verifier->TestMemberIsIDReference(stream.transport_id,
-                                      RTCTransportStats::kType);
-    verifier->TestMemberIsIDReference(stream.codec_id, RTCCodecStats::kType);
+    verifier.TestMemberIsIDReference(stream.transport_id,
+                                     RTCTransportStats::kType);
+    verifier.TestMemberIsIDReference(stream.codec_id, RTCCodecStats::kType);
+  }
+
+  void VerifyRTCSentRTPStreamStats(const RTCSentRtpStreamStats& sent_stream,
+                                   RTCStatsVerifier& verifier) {
+    VerifyRTCRTPStreamStats(sent_stream, verifier);
+    verifier.TestMemberIsDefined(sent_stream.packets_sent);
+    verifier.TestMemberIsDefined(sent_stream.bytes_sent);
   }
 
   bool VerifyRTCInboundRTPStreamStats(
       const RTCInboundRTPStreamStats& inbound_stream) {
     RTCStatsVerifier verifier(report_, &inbound_stream);
-    VerifyRTCRTPStreamStats(inbound_stream, &verifier);
+    VerifyRTCRTPStreamStats(inbound_stream, verifier);
+    verifier.TestMemberIsOptionalIDReference(
+        inbound_stream.remote_id, RTCRemoteOutboundRtpStreamStats::kType);
     if (inbound_stream.media_type.is_defined() &&
         *inbound_stream.media_type == "video") {
       verifier.TestMemberIsNonNegative<uint64_t>(inbound_stream.qp_sum);
@@ -928,7 +940,7 @@
     // TODO(https://crbug.com/webrtc/12532): Invoke
     // VerifyRTCReceivedRtpStreamStats() instead of VerifyRTCRTPStreamStats()
     // because they have a shared hierarchy now!
-    VerifyRTCRTPStreamStats(outbound_stream, &verifier);
+    VerifyRTCRTPStreamStats(outbound_stream, verifier);
     if (outbound_stream.media_type.is_defined() &&
         *outbound_stream.media_type == "video") {
       verifier.TestMemberIsIDReference(outbound_stream.media_source_id,
@@ -1021,16 +1033,16 @@
 
   void VerifyRTCReceivedRtpStreamStats(
       const RTCReceivedRtpStreamStats& received_rtp,
-      RTCStatsVerifier* verifier) {
+      RTCStatsVerifier& verifier) {
     VerifyRTCRTPStreamStats(received_rtp, verifier);
-    verifier->TestMemberIsNonNegative<double>(received_rtp.jitter);
-    verifier->TestMemberIsDefined(received_rtp.packets_lost);
+    verifier.TestMemberIsNonNegative<double>(received_rtp.jitter);
+    verifier.TestMemberIsDefined(received_rtp.packets_lost);
   }
 
   bool VerifyRTCRemoteInboundRtpStreamStats(
       const RTCRemoteInboundRtpStreamStats& remote_inbound_stream) {
     RTCStatsVerifier verifier(report_, &remote_inbound_stream);
-    VerifyRTCReceivedRtpStreamStats(remote_inbound_stream, &verifier);
+    VerifyRTCReceivedRtpStreamStats(remote_inbound_stream, verifier);
     verifier.TestMemberIsDefined(remote_inbound_stream.fraction_lost);
     verifier.TestMemberIsIDReference(remote_inbound_stream.local_id,
                                      RTCOutboundRTPStreamStats::kType);
@@ -1043,6 +1055,19 @@
     return verifier.ExpectAllMembersSuccessfullyTested();
   }
 
+  bool VerifyRTCRemoteOutboundRTPStreamStats(
+      const RTCRemoteOutboundRtpStreamStats& remote_outbound_stream) {
+    RTCStatsVerifier verifier(report_, &remote_outbound_stream);
+    VerifyRTCRTPStreamStats(remote_outbound_stream, verifier);
+    VerifyRTCSentRTPStreamStats(remote_outbound_stream, verifier);
+    verifier.TestMemberIsIDReference(remote_outbound_stream.local_id,
+                                     RTCOutboundRTPStreamStats::kType);
+    verifier.TestMemberIsNonNegative<double>(
+        remote_outbound_stream.remote_timestamp);
+    verifier.TestMemberIsDefined(remote_outbound_stream.reports_sent);
+    return verifier.ExpectAllMembersSuccessfullyTested();
+  }
+
   void VerifyRTCMediaSourceStats(const RTCMediaSourceStats& media_source,
                                  RTCStatsVerifier* verifier) {
     verifier->TestMemberIsDefined(media_source.track_identifier);
diff --git a/pc/rtc_stats_traversal.cc b/pc/rtc_stats_traversal.cc
index aa53dde..e579072 100644
--- a/pc/rtc_stats_traversal.cc
+++ b/pc/rtc_stats_traversal.cc
@@ -99,24 +99,36 @@
     AddIdIfDefined(track.media_source_id, &neighbor_ids);
   } else if (type == RTCPeerConnectionStats::kType) {
     // RTCPeerConnectionStats does not have any neighbor references.
-  } else if (type == RTCInboundRTPStreamStats::kType ||
-             type == RTCOutboundRTPStreamStats::kType) {
-    const auto& rtp = static_cast<const RTCRTPStreamStats&>(stats);
-    AddIdIfDefined(rtp.track_id, &neighbor_ids);
-    AddIdIfDefined(rtp.transport_id, &neighbor_ids);
-    AddIdIfDefined(rtp.codec_id, &neighbor_ids);
-    if (type == RTCOutboundRTPStreamStats::kType) {
-      const auto& outbound_rtp =
-          static_cast<const RTCOutboundRTPStreamStats&>(stats);
-      AddIdIfDefined(outbound_rtp.media_source_id, &neighbor_ids);
-      AddIdIfDefined(outbound_rtp.remote_id, &neighbor_ids);
-    }
+  } else if (type == RTCInboundRTPStreamStats::kType) {
+    const auto& inbound_rtp =
+        static_cast<const RTCInboundRTPStreamStats&>(stats);
+    AddIdIfDefined(inbound_rtp.remote_id, &neighbor_ids);
+    AddIdIfDefined(inbound_rtp.track_id, &neighbor_ids);
+    AddIdIfDefined(inbound_rtp.transport_id, &neighbor_ids);
+    AddIdIfDefined(inbound_rtp.codec_id, &neighbor_ids);
+  } else if (type == RTCOutboundRTPStreamStats::kType) {
+    const auto& outbound_rtp =
+        static_cast<const RTCOutboundRTPStreamStats&>(stats);
+    AddIdIfDefined(outbound_rtp.remote_id, &neighbor_ids);
+    AddIdIfDefined(outbound_rtp.track_id, &neighbor_ids);
+    AddIdIfDefined(outbound_rtp.transport_id, &neighbor_ids);
+    AddIdIfDefined(outbound_rtp.codec_id, &neighbor_ids);
+    AddIdIfDefined(outbound_rtp.media_source_id, &neighbor_ids);
   } else if (type == RTCRemoteInboundRtpStreamStats::kType) {
     const auto& remote_inbound_rtp =
         static_cast<const RTCRemoteInboundRtpStreamStats&>(stats);
     AddIdIfDefined(remote_inbound_rtp.transport_id, &neighbor_ids);
     AddIdIfDefined(remote_inbound_rtp.codec_id, &neighbor_ids);
     AddIdIfDefined(remote_inbound_rtp.local_id, &neighbor_ids);
+  } else if (type == RTCRemoteOutboundRtpStreamStats::kType) {
+    const auto& remote_outbound_rtp =
+        static_cast<const RTCRemoteOutboundRtpStreamStats&>(stats);
+    // Inherited from `RTCRTPStreamStats`.
+    AddIdIfDefined(remote_outbound_rtp.track_id, &neighbor_ids);
+    AddIdIfDefined(remote_outbound_rtp.transport_id, &neighbor_ids);
+    AddIdIfDefined(remote_outbound_rtp.codec_id, &neighbor_ids);
+    // Direct members of `RTCRemoteOutboundRtpStreamStats`.
+    AddIdIfDefined(remote_outbound_rtp.local_id, &neighbor_ids);
   } else if (type == RTCAudioSourceStats::kType ||
              type == RTCVideoSourceStats::kType) {
     // RTC[Audio/Video]SourceStats does not have any neighbor references.
diff --git a/stats/rtcstats_objects.cc b/stats/rtcstats_objects.cc
index 3a12eea..656cb4a 100644
--- a/stats/rtcstats_objects.cc
+++ b/stats/rtcstats_objects.cc
@@ -608,7 +608,32 @@
 
 // clang-format off
 WEBRTC_RTCSTATS_IMPL(
+    RTCSentRtpStreamStats, RTCRTPStreamStats, "sent-rtp",
+    &packets_sent,
+    &bytes_sent)
+// clang-format on
+
+RTCSentRtpStreamStats::RTCSentRtpStreamStats(const std::string&& id,
+                                             int64_t timestamp_us)
+    : RTCSentRtpStreamStats(std::string(id), timestamp_us) {}
+
+RTCSentRtpStreamStats::RTCSentRtpStreamStats(std::string&& id,
+                                             int64_t timestamp_us)
+    : RTCRTPStreamStats(std::move(id), timestamp_us),
+      packets_sent("packetsSent"),
+      bytes_sent("bytesSent") {}
+
+RTCSentRtpStreamStats::RTCSentRtpStreamStats(const RTCSentRtpStreamStats& other)
+    : RTCRTPStreamStats(other),
+      packets_sent(other.packets_sent),
+      bytes_sent(other.bytes_sent) {}
+
+RTCSentRtpStreamStats::~RTCSentRtpStreamStats() {}
+
+// clang-format off
+WEBRTC_RTCSTATS_IMPL(
     RTCInboundRTPStreamStats, RTCReceivedRtpStreamStats, "inbound-rtp",
+    &remote_id,
     &packets_received,
     &fec_packets_received,
     &fec_packets_discarded,
@@ -665,6 +690,7 @@
 RTCInboundRTPStreamStats::RTCInboundRTPStreamStats(std::string&& id,
                                                    int64_t timestamp_us)
     : RTCReceivedRtpStreamStats(std::move(id), timestamp_us),
+      remote_id("remoteId"),
       packets_received("packetsReceived"),
       fec_packets_received("fecPacketsReceived"),
       fec_packets_discarded("fecPacketsDiscarded"),
@@ -716,6 +742,7 @@
 RTCInboundRTPStreamStats::RTCInboundRTPStreamStats(
     const RTCInboundRTPStreamStats& other)
     : RTCReceivedRtpStreamStats(other),
+      remote_id(other.remote_id),
       packets_received(other.packets_received),
       fec_packets_received(other.fec_packets_received),
       fec_packets_discarded(other.fec_packets_discarded),
@@ -914,6 +941,37 @@
 RTCRemoteInboundRtpStreamStats::~RTCRemoteInboundRtpStreamStats() {}
 
 // clang-format off
+WEBRTC_RTCSTATS_IMPL(
+    RTCRemoteOutboundRtpStreamStats, RTCSentRtpStreamStats,
+    "remote-outbound-rtp",
+    &local_id,
+    &remote_timestamp,
+    &reports_sent)
+// clang-format on
+
+RTCRemoteOutboundRtpStreamStats::RTCRemoteOutboundRtpStreamStats(
+    const std::string& id,
+    int64_t timestamp_us)
+    : RTCRemoteOutboundRtpStreamStats(std::string(id), timestamp_us) {}
+
+RTCRemoteOutboundRtpStreamStats::RTCRemoteOutboundRtpStreamStats(
+    std::string&& id,
+    int64_t timestamp_us)
+    : RTCSentRtpStreamStats(std::move(id), timestamp_us),
+      local_id("localId"),
+      remote_timestamp("remoteTimestamp"),
+      reports_sent("reportsSent") {}
+
+RTCRemoteOutboundRtpStreamStats::RTCRemoteOutboundRtpStreamStats(
+    const RTCRemoteOutboundRtpStreamStats& other)
+    : RTCSentRtpStreamStats(other),
+      local_id(other.local_id),
+      remote_timestamp(other.remote_timestamp),
+      reports_sent(other.reports_sent) {}
+
+RTCRemoteOutboundRtpStreamStats::~RTCRemoteOutboundRtpStreamStats() {}
+
+// clang-format off
 WEBRTC_RTCSTATS_IMPL(RTCMediaSourceStats, RTCStats, "parent-media-source",
     &track_identifier,
     &kind)