コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

React NativeのモバイルアプリにDatadog RUMを導入して学んだ設計と落とし穴

こんにちは、新規事業のプロダクト開発を担当している杉山です。

Webアプリケーションの監視は、サーバーのログやメトリクスを見れば多くの問題に気づけます。しかしモバイルアプリはそうはいきません。ユーザーの端末で起きたクラッシュやエラーは、ユーザーからの問い合わせがくるまで開発チームには見えません。

私たちのチームでは、React Native(Expo)で開発しているモバイルアプリにDatadog RUM SDKを導入し、監視基盤を構築しました。この記事では、設計判断と実際のコードを交えながら、導入の全体像を紹介します。

Datadog RUMとは

Datadog RUM(Real User Monitoring)は、Webやモバイルアプリケーションの実際のユーザー体験をエンドツーエンドで可視化するサービスです。ユーザーセッション中のエラー、クラッシュ、ユーザーアクションを自動的に収集し、デバイス・OS・バージョンなどの切り口で分析できます。

RUMにはセッションリプレイやパフォーマンスモニタリングなど多くの機能がありますが、今回の導入ではまずエラー検知とログの一元管理に焦点を当てています。 私たちが特に活用しているのは以下の機能です。

Error Tracking

大量のエラーを類似パターンごとに自動グルーピングし、影響を受けるユーザー数やデバイス情報とともに表示します。個々のエラーログを1件ずつ追うのではなく、「どのエラーが何人に影響しているか」を俯瞰できるため、対応の優先度判断が格段に楽になります。

フルスタック連携

バックエンドのトレースやログとフロントエンドのエラーを紐付けて、ユーザー端末からAPIサーバーまで一貫した調査が可能です。「このエラーはフロントの問題かAPIの問題か」を切り分ける際に、Datadogの画面内で完結できます。

選定理由

コドモンでは全体的にDatadogにログを集約する設計にしています。APIやインフラのログもDatadogに送っているため、モバイルアプリのログも同じ場所で見られるようにしたかったからです。SentryやFirebase Crashlyticsなど他のサービスも検討しましたが、会社での導入実績やログの一元管理のしやすさを考慮して、Datadogを選択しました。 以下で書く内容はDatadogだけでなく、他の監視サービスを使う場合でも参考になる設計判断や実装の工夫が多いと思います。 モバイルアプリの監視基盤をこれから構築する方や、既存の基盤を見直す方の参考になれば幸いです。

全体設計

ログの設計では何を・どこに・どう送るかを決める必要があります。モバイルアプリのログは、APIやインフラのログと比べて、ユーザーの端末で起きたエラーをいかに見つけやすくするかが重要だと考えました。

そこで、エラーはRUMのError Trackingに集約し、警告や情報ログはLogsに送る設計にしました。理由は、エラー・クラッシュ・パフォーマンスをひとつの画面で見られるようにするためです。RUMダッシュボードでユーザーセッションと紐付けてエラーを確認できるため、調査が非常に楽になると考えたからです。

ログ種別と送信先のマッピング

最終的にイベントをどこに送るかを以下の表にまとめました。

ログ種別 送信先 用途
console.error RUM Error Tracking + Logs (error) エラー検知・アラート
console.warn Logs (warn) 警告の記録
console.info / console.log Logs (info) ※本番では送信しない 開発時のデバッグ
ネイティブクラッシュ RUM Error Tracking クラッシュ検知

データフロー

少し見づらいですが、最終的にログがどこを経由してDatadogに送られているかを図にしています。 consoleInterceptorの具体的な内容に関しては後述します。

ReactNativeのモバイルアプリで発生したログをどこにどのように送信するか

保持期間

APIやインフラではDatadogとは別にS3などへの長期保存を用意しています。しかしモバイルアプリではすべてDatadog上で完結させています。ユーザー端末で起きたエラーは基本的に数日以内に対応するため、長期保存の必要性は低いと判断しました。

DatadogのLogsの保持期間は短めに設定しています。DatadogからS3へのエクスポートも可能ですが、モバイルアプリのログで数ヶ月前まで遡って調査するケースは稀なため、この設計にしています。

アラート設計

設定しているアラートは2つあります。

  1. 新規エラー検知: RUM Error Trackingで新しいエラーパターンが出現したら通知
  2. クラッシュフリー率の低下: クラッシュ率が閾値を超えたら通知

クラッシュ率の監視を「件数」ではなく「割合」にしたのは、アクティブユーザー数が増減しても閾値が安定するためです。 閾値は実際に運用してみなければ適切な値はわからないため、運用しながら適宜調整していく予定です。

サンプリング100%にした理由

export const SESSION_SAMPLING_RATES: Record<AppEnvironment, number> = {
  local: 0,
  development: 100,
  staging: 100,
  production: 100
} as const

Datadog RUMのサンプリングレートは全環境で100%(localは除外)にしています。サンプリングを下げるとコストは抑えられますが、エラーの取りこぼしが起きてしまいます。モバイルアプリは1セッションあたりのイベント数がWebに比べて少なく、取りこぼしのリスクに対してサンプリングを下げるメリットが薄いです。Datadogもモバイルアプリでは100%サンプリングを推奨しています。

SDK初期化

設定ファイル

config.ts でサンプリングレートやサービス名を一元管理しています。

export const DATADOG_SERVICE_NAME = "SERVICE_NAME"
export const DATADOG_SITE = "US1"

export const SESSION_SAMPLING_RATES: Record<AppEnvironment, number> = {
  local: 0,
  development: 100,
  staging: 100,
  production: 100
} as const

export const RESOURCE_TRACING_SAMPLING_RATE = 100

ローカル開発時はDatadog SDKを動かさないため、サンプリングレート0にしています。 また、それ以外の環境では100%に設定しています。これは初期段階ではエラーを完全にキャッチしたいためです。将来的には、運用状況に応じて環境ごとに適切なサンプリングレートを設定していく予定です。

SDK初期化

init.ts で実際にSDKを初期化します。

export const initializeDatadog = async () => {
  if (!(process.env.EXPO_PUBLIC_DATADOG_CLIENT_TOKEN
        && process.env.EXPO_PUBLIC_DATADOG_RUM_APPLICATION_ID)) {
    console.warn("⚠️ Missing Datadog configuration, skipping initialization")
    return
  }

  const env = getAppEnv()

  // Datadog上でenvironmentタグとして使う値を環境ごとにマッピング
  // localに関しては、Datadogには送信しないが、型の都合上必要なのでlocalのまま設定するだけ
  const datadogEnv: string = {
    local: "local",
    development: "dev",
    staging: "stg",
    production: "prd"
  }[env]

  const config = new DdSdkReactNativeConfiguration(
    process.env.EXPO_PUBLIC_DATADOG_CLIENT_TOKEN,
    datadogEnv,
    process.env.EXPO_PUBLIC_DATADOG_RUM_APPLICATION_ID,
    false, // ユーザーインタラクション追跡
    false, // XHRリソース追跡
    true   // エラー追跡
  )

  config.nativeCrashReportEnabled =
    process.env.EXPO_PUBLIC_DATADOG_NATIVE_CRASH_REPORT_ENABLED === "true"
  config.site = DATADOG_SITE
  config.sessionSamplingRate = getSessionSamplingRate(env)
  config.resourceTracingSamplingRate = RESOURCE_TRACING_SAMPLING_RATE
  config.firstPartyHosts = getFirstPartyHosts()
  config.verbosity = getVerbosity(env)
  config.serviceName = DATADOG_SERVICE_NAME
  config.version = Constants.expoConfig.version

  await DdSdkReactNative.initialize(config)
}

環境変数が設定されていない場合はスキップします。CIやローカル開発で余計なエラーを出さないためです。

verbosityの罠

// 送信するログのレベルではなく、SDK自体が出力するログのレベルを制御する設定。
// SDKの動作確認やデバッグに役立つ。
// ここを修正しても、Datadogへ送信されるconsoleレベルを制御できるわけではない
const getVerbosity = (env: AppEnvironment): SdkVerbosity => {
  switch (env) {
    case "development":
      return SdkVerbosity.INFO
    case "staging":
      return SdkVerbosity.ERROR
    case "production":
      return SdkVerbosity.ERROR
    default:
      return SdkVerbosity.ERROR
  }
}

導入初期にハマったのが verbosity の意味です。名前から「Datadogに送信するログのレベルを制御する設定」だと思い込んでいましたが、実際には、SDK自体のデバッグログの出力レベルを制御する設定です。

つまり、SdkVerbosity.ERROR にしても console.info がDatadogに送られなくなるわけではありません。アプリのログレベル制御は、後述するconsoleインターセプターで自分で実装する必要があります。

Datadog SDKのverbosityの挙動

consoleインターセプター

Datadog RUM SDKはconsole出力を自動収集しない

Datadog RUM SDKを入れただけでは、console.errorconsole.warn はDatadogに送られません。

WebのDatadog RUM SDKには forwardConsoleLogs というオプションがあり、consoleの出力を自動転送できます。しかし、React Native向けのSDK(expo-datadog)にはこのオプションが存在しません。自分でconsoleメソッドをインターセプトし、Datadogに送信する仕組みを作る必要があります。

consoleInterceptor.ts

consoleInterceptor.ts で、consoleメソッドを上書きしています。

import { DdLogs, DdRum, ErrorSource } from "@datadog/mobile-react-native"

let originalConsole: {
  log: typeof console.log
  warn: typeof console.warn
  error: typeof console.error
  debug: typeof console.debug
  info: typeof console.info
} | null = null

export const initializeConsoleInterceptor = () => {
  const env = getAppEnv()

  // local環境ではインターセプターを無効化
  if (env === "local") {
    return
  }

  // Datadog SDK初期化後のconsoleメソッドを保存
  originalConsole = {
    log: console.log,
    warn: console.warn,
    error: console.error,
    debug: console.debug,
    info: console.info
  }

  // console.warnをインターセプト → Datadog Logsへ
  console.warn = (...args: unknown[]) => {
    originalConsole?.warn(...args)

    const message = args
      .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
      .join(" ")

    DdLogs.warn(message, {
      source: "console.warn",
      timestamp: new Date().toISOString()
    }).catch((err) => {
      originalConsole?.error("Failed to send warn log to Datadog:", err)
    })
  }

  // console.errorをインターセプト → RUM Error Trackingへ
  console.error = (...args: unknown[]) => {
    originalConsole?.error(...args)

    const message = args
      .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
      .join(" ")

    // エラーオブジェクトがある場合はスタックトレースを抽出
    const errorArg = args.find((arg) => arg instanceof Error) as Error | undefined
    const stacktrace = errorArg?.stack || new Error().stack || ""

    DdRum.addError(message, ErrorSource.CONSOLE, stacktrace, {}).catch((err) => {
      originalConsole?.error("Failed to send error to Datadog RUM:", err)
    })
  }

  // console.info → Datadog Logs (本番では送信しない)
  console.info = (...args: unknown[]) => {
    if (getAppEnv() === "production") {
      return
    }
    originalConsole?.info(...args)

    const message = args
      .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
      .join(" ")

    DdLogs.info(message, {
      source: "console.info",
      timestamp: new Date().toISOString()
    }).catch((err) => {
      originalConsole?.error("Failed to send info log to Datadog:", err)
    })
  }

  // console.log → Datadog Logs (本番では送信しない)
  console.log = (...args: unknown[]) => {
    if (getAppEnv() === "production") {
      return
    }
    originalConsole?.info(...args)

    const message = args
      .map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
      .join(" ")

    DdLogs.info(message, {
      source: "console.log",
      timestamp: new Date().toISOString()
    }).catch((err) => {
      originalConsole?.error("Failed to send info log to Datadog:", err)
    })
  }
}

設計上の工夫

無限ループの防止

Datadogへの送信に失敗した際のエラーハンドリングでは、originalConsole?.error() を使っています。インターセプト済みの console.error を呼んでしまうと、そのエラーがまたDatadogに送信され、また失敗して…という無限ループに陥ります。

スタックトレースの抽出

console.error の引数にErrorオブジェクトが含まれている場合は、そのスタックトレースを抽出してDatadogに送ります。含まれていない場合は new Error().stack でインターセプター呼び出し時点のスタックトレースを代わりに付与します。デバッグ時にエラーの発生箇所を追えるようにするためです。

本番ではinfo/logを送信しない

console.infoconsole.log は開発・ステージング環境でのみDatadogに送信し、本番環境では送信しません。本番でノイズになるデバッグログを削減するためです。なお、本番環境ではDatadogへの送信だけでなく、コンソール出力自体も抑制しています。これは意図的な設計で、本番ビルドでのパフォーマンスへの影響を最小限にするためです。

初期化順序

以下はapp/_layout.tsx の初期化シーケンスです。

useEffect(() => {
  const initializeApp = async () => {
    await initializeDatadog()
    // Datadog初期化後にconsoleインターセプターを設定
    initializeConsoleInterceptor()
    ... (その他の初期化処理)
  }

  initializeApp()
}, [])

ここで重要なのは、initializeDatadog()initializeConsoleInterceptor() の順序です。

consoleインターセプターは DdRum.addError()DdLogs.warn() を呼び出します。Datadog SDKが初期化されていない状態でこれらを呼ぶと、エラーはどこにも送られません。SDKが未初期化だと黙って失敗するため、気づかないまま「監視が動いているはず」と思い込む危険があります。

originalConsole をインターセプター初期化時に保存しているので、Datadog SDK初期化後にキャプチャすることで、SDK側が行うconsoleの加工があっても影響を受けません。

ユーザー情報の紐付け

エラーが発生したとき、「どのユーザーで起きたか」がわかると調査が格段に楽になります。

import { setCustomUserId } from "@microsoft/react-native-clarity"
import { getAnalytics, setUserId } from "@react-native-firebase/analytics"
import { DdSdkReactNative } from "expo-datadog"

export const useSetUserInfo = () => {
  const setUserInfo = (userInfo: {
    id: string
    name?: string
    email?: string
    extraInfo?: Record<string, unknown>
  }) => {
    setUserId(getAnalytics(), userInfo.id).catch((error) => {
      console.warn("Firebase setUserId failed:", error)
    })
    DdSdkReactNative.setUserInfo(userInfo).catch((error) => {
      console.warn("Datadog setUserInfo failed:", error)
    })
    setCustomUserId(userInfo.id).catch((error) => {
      console.warn("Failed to set Clarity user:", error)
    })
  }

  return { setUserInfo }
}

Datadog、Firebase Analytics、Microsoft Clarityの3サービスに同一のユーザーIDを設定しています。これにより、Datadogでエラーを見つけたときにClarityのセッション録画で実際のユーザー操作を確認する、といった横断的な調査が可能になります。

各サービスへの設定は非同期で行い、1つが失敗しても他に影響しないようにしています。

Expoプラグイン設定

app.config.tsexpo-datadog プラグインを設定します。

// Datadogはlocal環境では動作させない
if (APP_ENV !== "local") {
  plugins.push(
    [
      "expo-datadog",
      {
        serviceName: DATADOG_SERVICE_NAME
      }
    ],
    // ... その他のプラグイン
  )
}

expo-datadog プラグインはネイティブのdSYMアップロード等を設定します。ローカル開発ではDatadogを使わないため、APP_ENV !== "local" でプラグイン自体を除外しています。プラグインを含めるとビルド時間が伸びるため、不要な環境では外すのがよいでしょう。

まとめ

Datadog RUM SDKをReact Nativeアプリに導入する際、特に注意すべき点を改めて整理します。

verbosityはSDKのデバッグログ設定

verbosity はDatadogに送信するログレベルの制御ではなく、SDK自体のデバッグ出力の制御です。アプリのログレベル制御は自前で実装する必要があります。

console出力は自動収集されない

Web向けSDKと違い、React Native向けSDKには forwardConsoleLogs がありません。consoleインターセプターを自分で実装し、DdRum.addError()DdLogs.warn() で明示的に送信する必要があります。

初期化順序を守る

Datadog SDK → consoleインターセプターの順序は必須です。逆にすると、エラーが黙って失われます。