統計情報

Sora iOS SDK では、Sora を利用した WebRTC セッション中に iOS クライアントが送信している音声・映像・データパケットの状態を確認できます。 ビットレートや RTT、パケットロスといった統計情報を、W3C WebRTC Stats 仕様の getStats に準拠した形式で取得します(https://www.w3.org/TR/webrtc-stats/)。

統計情報の取得

Sora iOS SDK では、Sora からの送信要求に応じて統計情報を定期的に自動で送信していますが、 MediaChannel.getStats() メソッドを利用することでクライアントの任意のタイミングで統計情報を取得することができます。

取得した統計情報は通信状況の確認・可視化等に利用することができます。

統計情報を取得して統計要素を抽出する実装サンプル

// 画面中のボタンをタップすることにより統計情報を取得するサンプル実装です
// 統計情報取得に必要な処理以外は省略しています

import Sora
import UIKit

class ViewController: UIViewController {
  // 接続完了後に設定される MediaChannelです
  // 統計情報の取得には Sora へ接続済みである必要があります
  var mediaChannel: MediaChannel?

  // 統計情報取得アクションです。ボタン等にアクションとして紐付けます
  @IBAction func getStats(_ sender: AnyObject) {
    // 接続していない状態で実行した場合は処理を抜けます
    guard let mediaChannel else {
      return
    }

    // getStats 実行により統計情報を取得します。
    // getStats は libwebrtc の統計情報取得メソッド(クロージャ)に、
    // 接続状況のチェック等を追加したラッパー関数のためクロージャ形式となっています
    mediaChannel.getStats { [weak self] result in
      // ViewController が解放済みの場合は処理を抜けます
      guard let self else { return }
      // getStats 結果の評価
      switch result {
      case .success(let stats):
        // stats は entries プロパティで StatisticsEntry のリストを持っています
        // ここでは例として、entries の中から type=outbound-rtp の要素を抽出します
        let outboundEntries = stats.entries.filter { $0.type == "outbound-rtp" }
        // StatisticsEntry は id, type(統計種別), timestamp(測定時刻[μs]), values を要素として持ちます
        // values は type ごとに異なる統計要素です
        for entry in outboundEntries {
          print("id: \(entry.id), timestamp: \(entry.timestamp), values: \(entry.values)")
        }

      case .failure(let error):
        // MediaChannel が切断済み、または WebRTC 側で統計取得に失敗した場合
        print("Stats Error:", error)
      }
    }
  }
}

統計情報の構造

統計情報は RTCStatsReport 型で取得されます。 [{ "type": "...", ... }] のように type ごとのレコードが並ぶイメージで、 ID をキーにしたレコードの集合となっています。

統計情報を取得して内容を表示する

統計情報サンプル

実際に取得される統計情報のサンプルを掲載します。

[
    {
        "ssrc" : 1853431412,
        "id" : "RIV1853431412",
        "localId" : "OTaudio_OhfC2B1V1853431412",
        "codecId" : "COTaudio_OhfC2B1_120_profile-id=0;x-google-start-bitrate=500",
        "transportId" : "Taudio_OhfC2B1",
        "kind" : "video",
        "jitter" : 0.0078770000000000003,
        "totalRoundTripTime" : 0.034118999999999997,
        "roundTripTime" : 0.034118999999999997,
        "fractionLost" : 0,
        "type" : "remote-inbound-rtp",
        "roundTripTimeMeasurements" : 1,
        "timestamp" : 1762916916047921,
        "packetsLost" : 0
    },
    {
        "id" : "P",
        "type" : "peer-connection",
        "dataChannelsClosed" : 0,
        "dataChannelsOpened" : 0,
        "timestamp" : 1762916916592191
    },
    {
        "type" : "media-source",
        "totalAudioEnergy" : 5.311656835890106e-06,
        "timestamp" : 1762916916592191,
        "kind" : "audio",
        "audioLevel" : 0.00048829615161595508,
        "trackIdentifier" : "mainAudio",
        "totalSamplesDuration" : 2.1699999999999977,
        "id" : "SA3"
    },
    {
        "payloadType" : 109,
        "clockRate" : 48000,
        "sdpFmtpLine" : "maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
        "id" : "COTaudio_OhfC2B1_109_maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
        "type" : "codec",
        "timestamp" : 1762916916592191,
        "transportId" : "Taudio_OhfC2B1",
        "mimeType" : "audio\/opus",
        "channels" : 2
    },
    {
        "mediaSourceId" : "SA3",
        "id" : "OTaudio_OhfC2B1A3036164645",
        "type" : "outbound-rtp",
        "kind" : "audio",
        "active" : true,
        "retransmittedPacketsSent" : 0,
        "targetBitrate" : 64000,
        "retransmittedBytesSent" : 0,
        "transportId" : "Taudio_OhfC2B1",
        "packetsSent" : 108,
        "mid" : "audio_OhfC2B",
        "totalPacketSendDelay" : 0,
        "codecId" : "COTaudio_OhfC2B1_109_maxplaybackrate=48000;minptime=10;sprop-stereo=1;stereo=1;usedtx=0;useinbandfec=1",
        "timestamp" : 1762916916592191,
        "headerBytesSent" : 2592,
        "nackCount" : 0,
        "ssrc" : 3036164645,
        "bytesSent" : 17388
    },
    {
        "...": "省略"
    }
]

Swift 6 言語モードに対応する Sendable な統計情報を取得する

MediaChannel.getStats() は可変 (mutable) な Statistics を返すため、結果を actor 境界や Task の @Sendable クロージャへ渡せません。 MediaChannel.getStatsSnapshot(handler:) と MediaChannel.getStatsSnapshot() は、 MediaChannel.getStats() と同じ統計情報を、不変 (immutable) で Sendable な StatisticsSnapshot として取得します。 統計情報を actor 境界や Task の @Sendable クロージャへ渡す場合は、こちらを利用してください。

StatisticsSnapshot と StatisticsEntrySnapshot は Statistics と StatisticsEntry の主要なプロパティを同じ名前で持ち、統計値の型だけが [String: JSONValue] になります。

StatisticsSnapshot は timestamp (収集時刻 [μs]) と entries ( StatisticsEntrySnapshot の一覧) を持ちます。 StatisticsEntrySnapshot は id (エントリー ID)、 type (統計種別)、 timestamp (測定時刻 [μs])、 values (統計値) を持ちます。

Statistics.jsonObject に相当するプロパティは持たないため、 JSON へ変換する場合は JSONValue を利用してください。

クロージャで統計情報を取得する

MediaChannel.getStatsSnapshot(handler:) は結果を StatisticsSnapshot としてクロージャへ渡します。 クロージャの実行スレッドは保証されません。接続状態の判定で失敗する場合は呼び出し元のスレッドから同期的に呼ばれ、統計情報の取得まで進んだ場合は libwebrtc 側のスレッドから呼ばれます。

// mediaChannel は Sora へ接続済みの MediaChannel です
mediaChannel.getStatsSnapshot { result in
  switch result {
  case .success(let snapshot):
    // snapshot.entries は StatisticsEntrySnapshot の一覧です
    // ここでは type が outbound-rtp の要素を抽出します
    for entry in snapshot.entries where entry.type == "outbound-rtp" {
      print("id: \(entry.id), timestamp: \(entry.timestamp), values: \(entry.values)")
    }

  case .failure(let error):
    // MediaChannel が切断済み、または統計値を JSONValue へ変換できない場合
    print("Stats Error:", error)
  }
}

async で統計情報を取得する

MediaChannel.getStatsSnapshot() は MediaChannel.getStatsSnapshot(handler:) の async 版で、 StatisticsSnapshot を返します。 タスクをキャンセルした場合は CancellationError を投げます。

libwebrtc には統計取得をキャンセルする API が無いため、タスクをキャンセルしても RTCPeerConnection.statistics は完了まで実行されます。 その場合、取得した統計情報は返しません。

do {
  let snapshot = try await mediaChannel.getStatsSnapshot()
  let outboundEntries = snapshot.entries.filter { $0.type == "outbound-rtp" }
  _ = outboundEntries
} catch is CancellationError {
  // タスクをキャンセルした場合
} catch {
  // 予期しないエラー
  _ = error
}

StatisticsSnapshot を actor へ渡す

StatisticsSnapshot と StatisticsEntrySnapshot は Sendable のため、統計情報を actor 境界や Task の @Sendable クロージャへそのまま渡せます。

actor StatsStore {
  private var latest: StatisticsSnapshot?

  func store(_ snapshot: StatisticsSnapshot) {
    latest = snapshot
  }
}

統計値を読む

StatisticsEntrySnapshot.values は [String: JSONValue] のため、 StatisticsEntry.values のように NSObject を取り出すことはできません。 JSONValue の case ごとに値を取り出してください。

  • 数値 (整数を含む) は JSONValue.decimal 、真偽値は JSONValue.bool 、文字列は JSONValue.string 、配列は JSONValue.array 、オブジェクトは JSONValue.object です
  • Decimal が表現できる範囲を超える指数の値 ( 1e300 など) だけが JSONValue.double になります
  • JSONSerialization を経由するため、 NSNumber の小数は Decimal の値として入ります (例: 0.1 は 0.10000000000000001)
  • JSONValue は Encodable のため、 JSONEncoder で JSON データへ変換できます
// type が outbound-rtp の統計値を読む例です
let outboundEntry = snapshot.entries.first { $0.type == "outbound-rtp" }

// 数値は JSONValue.decimal として入ります
if case .decimal(let bytesSent)? = outboundEntry?.values["bytesSent"] {
  print("bytesSent: \(bytesSent)")
}

// 整数の統計値も JSONValue.decimal として入るため、整数として読む場合は Decimal から変換します
// ( JSONValue に整数の case はありません)
if case .decimal(let packetsSent)? = outboundEntry?.values["packetsSent"] {
  print("packetsSent: \(NSDecimalNumber(decimal: packetsSent).int64Value)")
}

// 文字列は JSONValue.string として入ります
if case .string(let kind)? = outboundEntry?.values["kind"] {
  print("kind: \(kind)")
}

エラーを扱う

MediaChannel.getStatsSnapshot(handler:) / MediaChannel.getStatsSnapshot() は次のエラーを返します。

  • SoraError.peerChannelError: Sora へ接続していない、 nativeChannel が取得できない、接続状態が遷移した、 MediaChannel の解放が始まった
  • SoraError.mediaChannelError: 統計値を JSONValue へ変換できない
  • CancellationError: async 版でタスクをキャンセルした
do {
  let snapshot = try await mediaChannel.getStatsSnapshot()
  _ = snapshot
} catch SoraError.peerChannelError(let reason) {
  // 接続状態または nativeChannel の問題
  _ = reason
} catch SoraError.mediaChannelError(let reason) {
  // 統計値を JSONValue へ変換できない
  _ = reason
} catch {
  // 予期しないエラー
  _ = error
}

Sendable な統計情報のサンプルコード

import Foundation
import Sora

// MARK: - StatisticsSnapshot を actor へ渡す

/// 統計情報を受け取る actor です。
///
/// `StatisticsSnapshot` / `StatisticsEntrySnapshot` は `Sendable` のため、 actor 境界を越えて
/// そのまま渡せます。
actor StatsStore {
  private var latest: StatisticsSnapshot?

  func store(_ snapshot: StatisticsSnapshot) {
    latest = snapshot
  }

  /// 保持している snapshot から `outbound-rtp` の `bytesSent` を読み出します。
  ///
  /// `StatisticsEntrySnapshot.values` は `[String: JSONValue]` のため、 `StatisticsEntry.values`
  /// のように `NSObject` を取り出すことはできません。 `JSONValue` の case で取り出します。
  func latestOutboundBytesSent() -> Decimal? {
    guard
      let entry = latest?.entries.first(where: { $0.type == "outbound-rtp" }),
      case .decimal(let bytesSent)? = entry.values["bytesSent"]
    else {
      return nil
    }
    return bytesSent
  }
}

// MARK: - async 版で統計情報を取得する

/// 統計情報を取得するサンプル実装です。
///
/// `MediaChannel` 自体は `Sendable` ではないため、 チャネルを actor 境界や `Task` の
/// `@Sendable` クロージャへ渡す場合は `nonisolated(unsafe)` などの隔離が必要です。
/// これは `getStats` / `getStatsSnapshot` のどちらを使う場合でも同じです。
final class SendableGetStatsExample {
  nonisolated(unsafe) let mediaChannel: MediaChannel
  let store = StatsStore()

  init(mediaChannel: MediaChannel) {
    self.mediaChannel = mediaChannel
  }

  /// `getStatsSnapshot()` で統計情報を取得し、 snapshot を actor へ渡します。
  func fetchSnapshot() async throws {
    do {
      // getStatsSnapshot() は不変で Sendable な StatisticsSnapshot を返します
      let snapshot = try await mediaChannel.getStatsSnapshot()
      // StatisticsSnapshot は Sendable のため、 actor 境界へそのまま渡せます
      await store.store(snapshot)
    } catch is CancellationError {
      // タスクをキャンセルした場合
    } catch SoraError.peerChannelError(let reason) {
      // 接続状態または nativeChannel の問題で失敗した場合
      _ = reason
    } catch SoraError.mediaChannelError(let reason) {
      // 統計値を JSONValue へ変換できない場合
      _ = reason
    } catch {
      // 予期しないエラー
      _ = error
    }
  }

  /// actor に保持した統計情報から `bytesSent` を読み出します。
  func printOutboundBytesSent() async {
    guard let bytesSent = await store.latestOutboundBytesSent() else {
      return
    }
    print("bytesSent: \(bytesSent)")
  }
}

// MARK: - クロージャ版で統計情報を取得する

/// `getStatsSnapshot(handler:)` で統計情報を取得し、 snapshot を actor へ渡します。
///
/// クロージャの実行スレッドは保証されません。 MainActor 隔離の文脈から渡したクロージャは
/// MainActor 隔離を継承するため、 クロージャの中ではクロージャを渡す API を呼ばず (`first(where:)` など)、
/// `@Sendable` なクロージャへ値を詰め替えてから main actor / main queue へ渡します。
func fetchSnapshotWithHandler(mediaChannel: MediaChannel, store: StatsStore) {
  mediaChannel.getStatsSnapshot { result in
    switch result {
    case .success(let snapshot):
      // snapshot は Sendable のため、 @Sendable クロージャへそのまま渡せます
      Task { @Sendable in
        await store.store(snapshot)
      }

    case .failure(let error):
      // MediaChannel が切断済み、または統計値を JSONValue へ変換できない場合
      print("Stats Error:", error)
    }
  }
}

// MARK: - 統計値を JSON へ変換する

/// 統計値を JSON データへ変換します。
///
/// `StatisticsSnapshot` は `Statistics.jsonObject` に相当するプロパティを持たないため、
/// `Encodable` な `JSONValue` を `JSONEncoder` で変換します。
func encodeStatsValues(of entry: StatisticsEntrySnapshot) throws -> Data {
  try JSONEncoder().encode(entry.values)
}