T-CREATOR

WebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値

WebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値

WebRTC を活用したリアルタイムコミュニケーションサービスの品質を数値で保証するには、適切な SLO(Service Level Objective)の設計が欠かせません。 本記事では、WebRTC 本番環境において重要となる「接続成功率」「初画出し時間」「通話継続率」という 3 つの核心的な指標に焦点を当て、それぞれの基準値設定から監視手法、そして実装例までを詳しく解説していきます。

背景

WebRTC サービスにおける品質保証の重要性

WebRTC を採用したビデオ通話やオンライン会議サービスでは、ユーザー体験(UX)が事業の成否を大きく左右します。 接続がうまくいかない、映像が遅延する、途中で切断されるといった問題は、ユーザーの離脱や信頼性低下に直結するため、サービス提供者は品質を客観的に測定し、継続的に改善する仕組みが必要です。

SLO は、サービスレベルを定量的に定義し、チーム全体で共有できる目標値を設定する手法です。 SLO を設計することで、開発・運用チームが同じ目線で品質改善に取り組めるようになり、アラート設定やインシデント対応の判断基準も明確になります。

SLO と SLI、SLA の関係

サービスレベル管理には、SLI・SLO・SLA という 3 つの概念が存在します。

mermaidflowchart TD
  sli["SLI<br/>Service Level Indicator<br/>実測値"]
  slo["SLO<br/>Service Level Objective<br/>内部目標値"]
  sla["SLA<br/>Service Level Agreement<br/>顧客との契約"]

  sli -->|達成状況を評価| slo
  slo -->|余裕を持たせて設定| sla
  sla -->|違反時に補償| user["顧客"]

SLI(Service Level Indicator) は、実際に計測される指標値そのものです。 接続成功率 98.5% や初画出し時間の中央値 1.2 秒といった、システムから取得できる生データを指します。

SLO(Service Level Objective) は、SLI に対して設定する内部目標値です。 「接続成功率 99% 以上を維持する」といった形で、チームが達成すべき品質ラインを定めます。

SLA(Service Level Agreement) は、顧客と交わす契約上の約束です。 SLO よりも余裕を持たせた値に設定し、SLA 違反時には補償などの対応が発生します。

この 3 つの関係を理解することで、社内でのモニタリング(SLI)、チーム目標(SLO)、顧客への約束(SLA)を適切に分けて管理できるようになります。

課題

WebRTC 特有の品質指標の複雑性

WebRTC では、HTTP ベースの Web サービスとは異なる品質指標が求められます。 単純なレスポンスタイムやエラーレートだけでは、リアルタイム通信の品質を正確に評価できません。

以下は、WebRTC サービスで発生しがちな課題です。

#課題内容影響範囲
1ネットワーク環境の多様性(Wi-Fi、モバイル、企業 VPN など)接続成功率、通話品質
2NAT トラバーサルや TURN サーバー経由の遅延初画出し時間、接続安定性
3デバイス性能のばらつき(CPU、カメラ、マイク)映像品質、音声品質
4複数拠点間の同時接続による負荷通話継続率、スケーラビリティ

これらの課題を踏まえると、WebRTC サービスには「接続が成功したか」「映像が素早く表示されるか」「通話が途切れずに継続できるか」という 3 つの視点が不可欠です。

基準値設定の難しさ

SLO を設定する際、どの数値を目標にすべきかの判断が難しいという問題があります。 厳しすぎる目標はチームの負担となり、緩すぎる目標ではユーザー体験が損なわれます。

また、サービスの特性によって適切な基準値は異なります。 1 対 1 のビデオ通話と、数十人が参加するウェビナーでは、求められる品質レベルや技術的な制約が大きく変わるため、画一的な基準を当てはめることはできません。

さらに、計測方法や集計期間の定義が曖昧だと、チーム間で認識のずれが生じ、正しい改善活動につながらないリスクもあります。

解決策

WebRTC における 3 つの核心的 SLO

WebRTC サービスの品質を総合的に管理するため、以下の 3 つの SLO を設計します。

mermaidflowchart LR
  user["ユーザー"] -->|1. 接続試行| conn["接続成功率<br/>SLO"]
  conn -->|2. 映像表示| fv["初画出し時間<br/>SLO"]
  fv -->|3. 通話維持| dur["通話継続率<br/>SLO"]
  dur -->|満足度向上| user

接続成功率(Connection Success Rate)

接続成功率は、ユーザーが WebRTC セッションの確立を試みた際に、正常に接続が完了する割合を示します。 この指標は、シグナリングサーバーの安定性、STUN/TURN サーバーの可用性、クライアント側の実装品質など、複数の要素が影響します。

推奨基準値

  • 標準目標: 99.0% 以上
  • 高品質目標: 99.5% 以上
  • 測定方法: 成功したセッション数 ÷ 試行されたセッション数 × 100

接続成功率が低下する主な原因には、ネットワーク障害、サーバー過負荷、ICE 候補の取得失敗などがあります。

初画出し時間(Time to First Frame)

初画出し時間は、接続開始からユーザーの画面に相手の映像が最初に表示されるまでの時間です。 この時間が長いと「接続できていないのでは?」とユーザーに不安を与え、離脱の原因となります。

推奨基準値

  • 標準目標: P95 で 3.0 秒以内
  • 高品質目標: P95 で 2.0 秒以内、中央値(P50)で 1.0 秒以内
  • 測定方法: クライアント側で ontrack イベント発火時刻から最初のフレーム描画時刻までを計測

初画出し時間には、シグナリング往復時間、ICE 接続確立時間、メディアストリーム開始時間が含まれます。 地理的に離れたユーザー間では、TURN サーバー経由となることで遅延が増加する傾向があります。

通話継続率(Call Retention Rate)

通話継続率は、開始された通話が意図せず切断されることなく、ユーザーが望む時間まで継続できた割合を示します。 途中で通話が切れてしまうと、ユーザー体験が著しく損なわれます。

推奨基準値

  • 標準目標: 98.0% 以上(5 分以上の通話)
  • 高品質目標: 99.0% 以上(30 分以上の通話)
  • 測定方法: 正常終了セッション数 ÷ 全セッション数 × 100

正常終了とは、ユーザー自身が通話終了ボタンを押した場合を指し、ネットワーク切断やエラーによる中断は異常終了としてカウントします。

SLO 設定時の考慮事項

SLO を設定する際には、以下のポイントを考慮すると効果的です。

#考慮ポイント具体例
1サービスの用途1 対 1 通話 vs 多人数会議 vs ライブ配信
2ターゲットユーザー企業内利用 vs 一般消費者向け
3過去実績データ既存システムの SLI 平均値・パーセンタイル値
4技術的制約インフラコスト、TURN サーバー帯域など
5競合サービス水準業界標準や競合他社の公開情報

過去データがない場合は、まず緩めの目標から始めて段階的に引き上げる方法が安全です。

パーセンタイル値による評価

平均値だけで品質を評価すると、一部のユーザーが抱える深刻な問題を見逃してしまう可能性があります。 たとえば、初画出し時間の平均が 1.5 秒でも、5% のユーザーが 10 秒以上待たされていたら、その 5% のユーザーは不満を感じるでしょう。

そのため、P50(中央値)、P95、P99 といったパーセンタイル値を併用することで、大多数のユーザー体験と、最悪ケースの両方をカバーできます。

mermaidflowchart LR
  data["計測データ"] --> p50["P50<br/>中央値<br/>典型的な体験"]
  data --> p95["P95<br/>95パーセンタイル<br/>悪い体験の境界"]
  data --> p99["P99<br/>99パーセンタイル<br/>最悪ケース"]

  p50 --> eval["総合評価"]
  p95 --> eval
  p99 --> eval

具体例

SLI の計測実装(TypeScript)

実際に SLI を計測するためのコード例を示します。 ここでは、クライアント側で接続成功率と初画出し時間を取得し、サーバーへ送信する実装を段階的に解説します。

インポートと型定義

まず、必要なライブラリと型定義を行います。

typescript// WebRTC 統計情報を収集するためのクライアントモジュール
import { v4 as uuidv4 } from 'uuid';

/**
 * SLI 計測用のイベントデータ型
 * セッションごとに記録される品質指標を格納
 */
interface WebRTCMetrics {
  sessionId: string; // セッション識別子
  userId: string; // ユーザー識別子
  connectionAttempted: number; // 接続試行時刻(Unix timestamp)
  connectionSuccess: boolean; // 接続成功フラグ
  connectionTime?: number; // 接続確立時刻
  firstFrameTime?: number; // 初画出し時刻
  disconnectTime?: number; // 切断時刻
  disconnectReason?: string; // 切断理由(normal / network / error)
}

メトリクス収集クラスの初期化

次に、WebRTC セッションのメトリクスを収集するクラスを作成します。

typescript/**
 * WebRTC メトリクス収集クラス
 * 接続開始から切断までの品質指標を追跡
 */
class WebRTCMetricsCollector {
  private metrics: WebRTCMetrics;
  private peerConnection: RTCPeerConnection | null = null;

  constructor(userId: string) {
    // メトリクスオブジェクトを初期化
    this.metrics = {
      sessionId: uuidv4(), // ユニークなセッション ID を生成
      userId: userId,
      connectionAttempted: Date.now(),
      connectionSuccess: false,
    };
  }

  /**
   * RTCPeerConnection インスタンスを設定
   * イベントリスナーを登録して品質指標を自動収集
   */
  public setPeerConnection(pc: RTCPeerConnection): void {
    this.peerConnection = pc;
    this.attachEventListeners();
  }
}

イベントリスナーの登録

WebRTC の各種イベントをキャッチして、メトリクスを記録します。

typescript/**
 * PeerConnection のイベントリスナーを登録
 * 接続成功、初画出し、切断を検知
 */
private attachEventListeners(): void {
  if (!this.peerConnection) return;

  // ICE 接続が確立された時点で接続成功とみなす
  this.peerConnection.oniceconnectionstatechange = () => {
    if (this.peerConnection?.iceConnectionState === 'connected') {
      this.metrics.connectionSuccess = true;
      this.metrics.connectionTime = Date.now();
      console.log('[Metrics] 接続成功:', this.getConnectionDuration());
    }
  };

  // リモートトラックが追加されたタイミングを記録
  this.peerConnection.ontrack = (event: RTCTrackEvent) => {
    if (event.streams && event.streams[0]) {
      // 映像トラックの場合のみ初画出し時間を記録
      if (event.track.kind === 'video' && !this.metrics.firstFrameTime) {
        this.metrics.firstFrameTime = Date.now();
        console.log('[Metrics] 初画出し時間:', this.getTimeToFirstFrame());
      }
    }
  };
}

メトリクス計算メソッド

収集したタイムスタンプから、実際の SLI 値を計算します。

typescript/**
 * 接続確立までの時間を計算(ミリ秒)
 * 接続試行から ICE connected までの経過時間
 */
public getConnectionDuration(): number | null {
  if (!this.metrics.connectionTime) return null;
  return this.metrics.connectionTime - this.metrics.connectionAttempted;
}

/**
 * 初画出し時間を計算(ミリ秒)
 * 接続試行から最初のビデオフレーム表示までの経過時間
 */
public getTimeToFirstFrame(): number | null {
  if (!this.metrics.firstFrameTime) return null;
  return this.metrics.firstFrameTime - this.metrics.connectionAttempted;
}

/**
 * 通話継続時間を計算(ミリ秒)
 * 接続確立から切断までの時間
 */
public getCallDuration(): number | null {
  if (!this.metrics.connectionTime || !this.metrics.disconnectTime) {
    return null;
  }
  return this.metrics.disconnectTime - this.metrics.connectionTime;
}

切断処理と送信

通話終了時に切断理由を記録し、サーバーへメトリクスを送信します。

typescript/**
 * 通話終了時に呼び出すメソッド
 * 切断時刻と理由を記録し、サーバーへ送信
 */
public disconnect(reason: 'normal' | 'network' | 'error'): void {
  this.metrics.disconnectTime = Date.now();
  this.metrics.disconnectReason = reason;

  console.log('[Metrics] 通話終了:', {
    継続時間: this.getCallDuration(),
    切断理由: reason,
  });

  // メトリクスをサーバーへ送信
  this.sendMetrics();
}

/**
 * メトリクスをバックエンド API へ送信
 * サーバー側で SLI 集計を行うためのデータ転送
 */
private async sendMetrics(): Promise<void> {
  try {
    await fetch('/api/metrics/webrtc', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(this.metrics),
    });
    console.log('[Metrics] 送信完了');
  } catch (error) {
    console.error('[Metrics] 送信失敗:', error);
  }
}

サーバー側での SLI 集計(Node.js / TypeScript)

クライアントから送信されたメトリクスを集計し、SLI を算出するバックエンド実装です。

API エンドポイントの定義

Express を使った API エンドポイントを作成します。

typescriptimport express, { Request, Response } from 'express';

const app = express();
app.use(express.json());

/**
 * WebRTC メトリクスを受信する API エンドポイント
 * クライアントから送信されたデータを DB へ保存
 */
app.post(
  '/api/metrics/webrtc',
  async (req: Request, res: Response) => {
    try {
      const metrics: WebRTCMetrics = req.body;

      // データベースへ保存(例: MongoDB)
      await saveMetricsToDB(metrics);

      res.status(200).json({ message: 'Metrics received' });
    } catch (error) {
      console.error('Metrics save error:', error);
      res
        .status(500)
        .json({ error: 'Failed to save metrics' });
    }
  }
);

接続成功率の集計

過去 1 時間の接続成功率を計算する関数です。

typescript/**
 * 接続成功率を計算する関数
 * 過去 1 時間のデータから成功率を算出
 */
async function calculateConnectionSuccessRate(): Promise<number> {
  const oneHourAgo = Date.now() - 60 * 60 * 1000;

  // 過去 1 時間のメトリクスを取得(例: MongoDB クエリ)
  const metrics = await fetchMetricsFromDB({
    connectionAttempted: { $gte: oneHourAgo },
  });

  const totalAttempts = metrics.length;
  const successfulAttempts = metrics.filter(
    (m) => m.connectionSuccess
  ).length;

  // 成功率をパーセンテージで返す
  return totalAttempts > 0
    ? (successfulAttempts / totalAttempts) * 100
    : 0;
}

初画出し時間のパーセンタイル計算

P50、P95、P99 を算出する関数です。

typescript/**
 * 初画出し時間のパーセンタイル値を計算
 * P50(中央値)、P95、P99 を返す
 */
async function calculateTimeToFirstFramePercentiles(): Promise<{
  p50: number;
  p95: number;
  p99: number;
}> {
  const oneHourAgo = Date.now() - 60 * 60 * 1000;

  // 過去 1 時間で初画出しに成功したメトリクスのみ取得
  const metrics = await fetchMetricsFromDB({
    connectionAttempted: { $gte: oneHourAgo },
    firstFrameTime: { $exists: true },
  });

  // 初画出し時間を配列として抽出
  const times = metrics
    .map((m) => m.firstFrameTime! - m.connectionAttempted)
    .sort((a, b) => a - b);

  if (times.length === 0) {
    return { p50: 0, p95: 0, p99: 0 };
  }

  // パーセンタイル値を計算
  const p50 = times[Math.floor(times.length * 0.5)];
  const p95 = times[Math.floor(times.length * 0.95)];
  const p99 = times[Math.floor(times.length * 0.99)];

  return { p50, p95, p99 };
}

通話継続率の集計

正常終了した通話の割合を計算します。

typescript/**
 * 通話継続率を計算する関数
 * 正常終了した通話の割合を返す
 */
async function calculateCallRetentionRate(): Promise<number> {
  const oneHourAgo = Date.now() - 60 * 60 * 1000;

  // 過去 1 時間で 5 分以上継続した通話を対象
  const metrics = await fetchMetricsFromDB({
    connectionAttempted: { $gte: oneHourAgo },
    disconnectTime: { $exists: true },
  });

  // 5 分(300,000 ミリ秒)以上の通話のみフィルタ
  const longCalls = metrics.filter((m) => {
    if (!m.connectionTime || !m.disconnectTime)
      return false;
    const duration = m.disconnectTime - m.connectionTime;
    return duration >= 300000;
  });

  const totalCalls = longCalls.length;
  const normalEndCalls = longCalls.filter(
    (m) => m.disconnectReason === 'normal'
  ).length;

  return totalCalls > 0
    ? (normalEndCalls / totalCalls) * 100
    : 0;
}

モニタリングとアラート設定

SLI を継続的に監視し、SLO を下回った場合にアラートを発報する仕組みが重要です。

Prometheus + Grafana によるダッシュボード

Prometheus でメトリクスを収集し、Grafana でダッシュボードを構築する例です。

typescriptimport { Counter, Histogram, register } from 'prom-client';

/**
 * Prometheus メトリクス定義
 * 接続試行回数と成功回数をカウント
 */
const connectionAttempts = new Counter({
  name: 'webrtc_connection_attempts_total',
  help: 'Total number of WebRTC connection attempts',
  labelNames: ['status'], // success / failure
});

/**
 * 初画出し時間をヒストグラムで記録
 * バケット範囲: 0.5秒〜10秒
 */
const timeToFirstFrame = new Histogram({
  name: 'webrtc_time_to_first_frame_seconds',
  help: 'Time from connection start to first frame displayed',
  buckets: [0.5, 1.0, 2.0, 3.0, 5.0, 10.0],
});

/**
 * 通話終了イベントをカウント
 * 正常終了と異常終了を分類
 */
const callEndEvents = new Counter({
  name: 'webrtc_call_end_total',
  help: 'Total number of call end events',
  labelNames: ['reason'], // normal / network / error
});

メトリクス記録処理

受信したメトリクスを Prometheus に記録します。

typescript/**
 * メトリクスを Prometheus に記録する関数
 * DB 保存と並行して実行
 */
function recordPrometheusMetrics(
  metrics: WebRTCMetrics
): void {
  // 接続成功・失敗をカウント
  if (metrics.connectionSuccess) {
    connectionAttempts.inc({ status: 'success' });
  } else {
    connectionAttempts.inc({ status: 'failure' });
  }

  // 初画出し時間を記録
  if (metrics.firstFrameTime) {
    const ttff =
      (metrics.firstFrameTime -
        metrics.connectionAttempted) /
      1000;
    timeToFirstFrame.observe(ttff);
  }

  // 通話終了理由を記録
  if (metrics.disconnectReason) {
    callEndEvents.inc({ reason: metrics.disconnectReason });
  }
}

Grafana アラートルール設定例

Grafana で SLO 違反時にアラートを発報する設定を記述します。

yaml# Grafana アラートルール(YAML 形式)
groups:
  - name: webrtc_slo_alerts
    interval: 1m
    rules:
      # 接続成功率が 99% を下回った場合
      - alert: WebRTCConnectionSuccessRateLow
        expr: |
          (
            sum(rate(webrtc_connection_attempts_total{status="success"}[5m]))
            /
            sum(rate(webrtc_connection_attempts_total[5m]))
          ) < 0.99
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: 'WebRTC 接続成功率が低下しています'
          description: '過去 5 分間の接続成功率が 99% を下回りました'

      # 初画出し時間の P95 が 3 秒を超えた場合
      - alert: WebRTCTimeToFirstFrameHigh
        expr: |
          histogram_quantile(0.95,
            rate(webrtc_time_to_first_frame_seconds_bucket[5m])
          ) > 3.0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: '初画出し時間が遅延しています'
          description: 'P95 初画出し時間が 3 秒を超えました'

SLO レポート生成

週次・月次で SLO 達成状況をレポート化し、チーム全体で共有します。

typescript/**
 * SLO レポートを生成する関数
 * 過去 7 日間のデータから達成状況を算出
 */
async function generateSLOReport(): Promise<{
  period: string;
  connectionSuccessRate: {
    value: number;
    target: number;
    achieved: boolean;
  };
  timeToFirstFrame: {
    p95: number;
    target: number;
    achieved: boolean;
  };
  callRetentionRate: {
    value: number;
    target: number;
    achieved: boolean;
  };
}> {
  const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;

  // 過去 7 日間のメトリクスを取得
  const metrics = await fetchMetricsFromDB({
    connectionAttempted: { $gte: sevenDaysAgo },
  });

  // 接続成功率を計算
  const totalAttempts = metrics.length;
  const successAttempts = metrics.filter(
    (m) => m.connectionSuccess
  ).length;
  const connectionSuccessRate =
    (successAttempts / totalAttempts) * 100;

  // 初画出し時間 P95 を計算
  const times = metrics
    .filter((m) => m.firstFrameTime)
    .map((m) => m.firstFrameTime! - m.connectionAttempted)
    .sort((a, b) => a - b);
  const p95 = times[Math.floor(times.length * 0.95)] / 1000;

  // 通話継続率を計算
  const longCalls = metrics.filter((m) => {
    if (!m.connectionTime || !m.disconnectTime)
      return false;
    return m.disconnectTime - m.connectionTime >= 300000;
  });
  const normalEnds = longCalls.filter(
    (m) => m.disconnectReason === 'normal'
  );
  const callRetentionRate =
    (normalEnds.length / longCalls.length) * 100;

  return {
    period: '過去 7 日間',
    connectionSuccessRate: {
      value: connectionSuccessRate,
      target: 99.0,
      achieved: connectionSuccessRate >= 99.0,
    },
    timeToFirstFrame: {
      p95: p95,
      target: 3.0,
      achieved: p95 <= 3.0,
    },
    callRetentionRate: {
      value: callRetentionRate,
      target: 98.0,
      achieved: callRetentionRate >= 98.0,
    },
  };
}

このレポート生成関数は、定期的に実行して Slack や Email で通知することで、チーム全体の品質意識を高めることができます。

まとめ

WebRTC 本番運用における SLO 設計では、「接続成功率」「初画出し時間」「通話継続率」という 3 つの核心指標を軸に、サービスの品質を定量的に管理することが重要です。

接続成功率は 99% 以上、初画出し時間は P95 で 3 秒以内、通話継続率は 98% 以上という基準値を目標に設定し、パーセンタイル値を活用することで、大多数のユーザーと最悪ケースの両方をカバーできます。

クライアント側でのメトリクス収集からサーバー側での集計、Prometheus + Grafana によるモニタリング、そして定期的な SLO レポート生成まで、一連の仕組みを整備することで、継続的な品質改善のサイクルを回すことが可能になります。

SLO 設計は一度設定して終わりではなく、サービスの成長やユーザーフィードバックに応じて、定期的に見直しと改善を繰り返すことが大切です。 本記事で紹介した手法を参考に、皆さんの WebRTC サービスに最適な SLO を設計していただければ幸いです。

関連リンク