コドモン Product Team Blog

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

ReactNativeのモバイルアプリ行動ログ設計

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

プロダクトをユーザーにとって使いやすいものに改善していくために、ユーザーの行動データは欠かせません。しかし、モバイルアプリの行動ログ(アナリティクス)設計は地味ながらも奥が深く、規模が大きくなるにつれて管理の複雑さが急激に増していきます。

本記事は、React Nativeを使ったモバイルアプリの行動ログの設計に課題を感じているエンジニアの方を中心にお役に立てればと思い書いた記事です。React Native(Expo)で開発しているモバイルアプリで採用している、YAMLベースのイベント定義+TypeScript自動生成というアプローチについて、設計の背景と実装の詳細を紹介します。

要件の整理

行動ログ基盤の設計にあたって、以下の要件がありました。

  • 複数サービスへの同時送信: Firebase Analytics(GA4)とMicrosoft Clarityの両方にイベントを送りたい
  • データ分析チームとの連携: イベント定義をエンジニア以外のメンバーも確認・提案できる形式にしたい
  • GA4制約への準拠: イベント名40文字制限、使用可能文字の制限、予約プレフィックス禁止といった制約を定義時点で検出したい
  • スケーラビリティ: 現時点で130以上のイベント定義があり、今後も増えていく見込み
  • 型安全性: TypeScriptで型安全にイベントを定義・使用

これらを満たしつつ、型安全性と保守性を両立させる設計が必要でした。

検討した2つのアプローチ

TypeScriptインターフェースの直接定義

最初に検討したのは、TypeScriptで直接イベント型を定義するアプローチです。

interface TapSignupButtonEvent {
  event_name: "tap_signup_button"
  screen: "/welcome"
}

interface SignUpDoneEvent {
  event_name: "sign_up_done"
  method: "email" | "google" | "apple" | "unknown"
}

型安全性は確保できますが、いくつかの問題があります。

  • エンジニア以外がイベント定義を確認することが困難
  • ビルド時のバリデーション(重複検出やGA4制約チェック)を挟むのが難しい

YAMLベースの定義 + TypeScript自動生成 - こちらを採用

もう1つの案は、YAMLファイルを単一ソースとして定義し、TypeScript型を自動生成するアプローチです。

最終的にこちらの案を採用しました。

構成としては以下のようになっています。

events.yml (単一ソース)
  ↓ generate.ts
  ├─ 重複チェック
  ├─ GA4制約バリデーション
  └─ TypeScript型定義生成 → types.ts
  ↓
useAnalytics() → Analytics Core → Adapters (Firebase, Clarity)

YAMLであれば生成ステップを挟むことでバリデーションを自然に組み込めます。

イベント定義(YAML)

events.yml に全イベントを定義しています。現時点で130以上のイベントが1ファイルに集約されています。

events:
  # ============================================================
  # ウェルカム画面
  # ============================================================
  - event_name: tap_signup_button
    description: 新しく始めるボタンタップ
    params:
      screen:
        type: path
        value: "/welcome"
        description: 画面のパス

  - event_name: sign_up_done
    description: サインアップ完了
    params:
      method:
        type: enum
        values: ["email", "google", "apple", "unknown"]
        description: "サインアップ方法"

パラメータには5つの型を用意しています。

説明
path 画面パス(リテラル値) value: "/welcome"
string 任意の文字列 動的な値
number 数値 カウント、ID等
boolean 真偽値 フラグ
enum 列挙型 values: ["email", "google"]

ここで、 path 型がポイントです。リテラル値として定義することで生成されるTypeScript型も、 "/welcome" のようなリテラル文字列型になります。このイベントはどの画面で発火するかがコンパイル時に保証されるということです。

同じイベント名を異なる画面で使うケース

実際のアプリでは、「次へ」ボタンのタップのように同じアクションが複数の画面で発生します。

  - event_name: tap_next_button
    description: 次へボタンタップ(画面1)
    params:
      screen:
        type: path
        value: "path/to/screen1"
        description: 画面のパス

  - event_name: tap_next_button
    description: 次へボタンタップ(画面2)
    params:
      screen:
        type: path
        value: "path/to/screen2"
        description: 画面のパス

同じ event_name でもパラメータが異なれば別のイベント定義として扱います。後述しますが、TypeScriptの型名は自動的に区別されます。

生成スクリプトとバリデーション

npm run codegen:analytics を実行すると、YAMLを読み取ってTypeScript型定義を生成します。この生成ステップの中で、いくつかのバリデーションを実行しています。

重複検出

イベント名+パラメータ構造をキーとした重複チェックを行います。

export function createEventKey(event: EventDef): string {
  const sortedParams = Object.keys(event.params)
    .sort()
    .map((key) => {
      const param = event.params[key]
      return `${key}:${param.type}:${param.value || param.values?.join("|") || ""}`
    })
    .join("|")

  return `${event.event_name}::${sortedParams}`
}

パラメータをソートしてからキーを生成しているため、定義順に依存しない一貫した重複検出ができます。同じ event_name でもパラメータ構造が異なれば別のイベントとして扱い、完全に同一の組み合わせが検出された場合のみエラーにします。

$ npm run codegen:analytics
❌ Duplicate event detected:
   Event name: tap_signup_button
   Description 1: 新しく始めるボタンタップ
   Description 2: 新しく始めるボタンタップ(複製)
Error: Duplicate event found: tap_signup_button

GA4制約バリデーション

GA4のイベント名にはいくつかの制約があります。これらを定義時点でチェックします。

export function validateGA4EventName(eventName: string): { valid: boolean; errors: string[] } {
  const errors: string[] = []

  // 最大40文字
  if (eventName.length > 40) {
    errors.push(`イベント名が40文字を超えています: ${eventName.length}文字`)
  }
  // 先頭は英字
  if (!/^[a-zA-Z]/.test(eventName)) {
    errors.push("イベント名は英字で始まる必要があります")
  }
  // 英字、数字、アンダースコアのみ
  if (!/^[a-zA-Z0-9_]+$/.test(eventName)) {
    errors.push("イベント名には英字、数字、アンダースコア(_)のみ使用できます")
  }
  // 予約プレフィックスの禁止
  const reservedPrefixes = ["ga_", "firebase_", "google_"]
  for (const prefix of reservedPrefixes) {
    if (eventName.startsWith(prefix)) {
      errors.push(`予約されたプレフィックス "${prefix}" で始まるイベント名は使用できません`)
    }
  }
  return { valid: errors.length === 0, errors }
}

送信しているのにデータが来ない場合の原因調査は非常に面倒なので、定義時点で弾けるのは大きなメリットがあります。

型名の一意性確保

同じ event_name を持つイベントが複数ある場合、path パラメータの値から識別子を生成して型名を区別します。なぜpath 型かというと、画面パスはイベントの発生場所を特定する重要な情報であり、同じアクションでも画面が違えば別のイベントとして扱うことが多いためです。ほとんどのイベントで path パラメータがあるため、これを利用して型名の衝突を解消しています。

function generateUniqueTypeName(event: EventDef, usedNames: Set<string>): string {
  const baseName = `${toPascalCase(event.event_name)}Event`

  if (!usedNames.has(baseName)) {
    return baseName  // 例: TapNextButtonEvent
  }

  // path型パラメータの値からsuffixを生成
  for (const [_paramName, paramDef] of Object.entries(event.params)) {
    if (paramDef.type === "path" && paramDef.value) {
      let suffix = paramDef.value.split("/").filter(Boolean).pop() || paramDef.value
      suffix = toPascalCase(suffix)
      const candidateName = `${toPascalCase(event.event_name) + suffix}Event`
      if (!usedNames.has(candidateName)) {
        return candidateName // 例: TapNextButtonScreen1Event
      }
    }
  }

  throw new Error(`型名の重複が解決できませんでした: ${baseName}`)
}

最初に定義された tap_next_buttonTapNextButtonEvent、2つ目以降は画面パスの末尾セグメントが付与されて TapNextButtonScreen1Event のようになります。解決できない場合はエラーとなり、開発者にイベント定義の見直しを促します。

生成されるTypeScript型

自動生成される types.ts はとても長いので、いくつか抜粋してみます。

// ⚠️ このファイルは自動生成されます。直接編集しないでください。

/**
 * 新しく始めるボタンタップ
 */
export interface TapSignupButtonEvent {
  /** イベント名 */
  event_name: "tap_signup_button"
  /** 画面のパス */
  screen: "/welcome"
}

/**
 * サインアップ完了
 */
export interface SignUpDoneEvent {
  /** イベント名 */
  event_name: "sign_up_done"
  /** サインアップ方法 */
  method: "email" | "google" | "apple" | "unknown"
}

すべてのイベント型はDiscriminated Unionとして統合されます。

export type AnalyticsEvent =
  | TapSignupButtonEvent
  | SignUpDoneEvent
  | TapLoginButtonEvent
  // ... 130以上のイベント型

export type EventName = AnalyticsEvent["event_name"]

event_name をリテラル型で持っているため、TypeScriptが「この event_name ならこのパラメータが必要」と自動的に判別してくれます。

アダプターパターンによる複数サービス対応

送信先サービスの違いをアダプターパターンで吸収しています。

export interface AnalyticsServiceAdapter {
  readonly name: string
  sendEvent(event: AnalyticsEvent): void
  setUser?(userInfo: UserInfo): void
}

FirebaseとClarityでは送信できるデータが異なります。Firebaseはイベント名+パラメータの両方を送信できますが、Clarityはセッションリプレイが主な用途のためイベント名のみの送信です。

// adapters/firebase.ts
import { getAnalytics, logEvent } from "@react-native-firebase/analytics"

export class FirebaseAdapter implements AnalyticsServiceAdapter {
  readonly name = "firebase"

  sendEvent(event: AnalyticsEvent): void {
    const { event_name, ...params } = event
    // エラーが後続の処理に影響しないように、ここで個別にキャッチして握り潰すようにしています
    logEvent(getAnalytics(), event_name, params as Record<string, any>)
      .catch((error) => console.warn(`[Firebase] Failed to log ${event_name}:`, error))
  }
  ...
}
// adapters/clarity.ts
export class ClarityAdapter implements AnalyticsServiceAdapter {
  readonly name = "clarity"

  sendEvent(event: AnalyticsEvent): void {
    // Clarityはイベント名のみ送信(パラメータは送信しない)
    sendCustomEvent(event.event_name)
      .catch((error) => console.warn(`[Clarity] Failed to send event:`, error))
  }
  ...
}

各アダプターがPromiseの .catch() で個別にエラーハンドリングしているため、一方のサービスの障害がもう一方に影響しません。新しいサービスを追加する場合は AnalyticsServiceAdapter を実装したクラスを1つ追加するだけです。

Coreロガー

Analytics クラスが、登録されたすべてのアダプターにイベントを配信します。

export class Analytics {
  private adapters: Map<string, AnalyticsServiceAdapter> = new Map()

  registerAdapter(adapter: AnalyticsServiceAdapter): void {
    this.adapters.set(adapter.name, adapter)
  }

  track(event: AnalyticsEvent): void {
    const targetServices = getServicesForEvent(event)

    for (const serviceName of targetServices) {
      const adapter = this.adapters.get(serviceName)
      if (adapter) {
        try {
          adapter.sendEvent(event)
        } catch (error) {
          console.error(`[Analytics] Failed to send to ${serviceName}:`, error)
        }
      }
    }
  }

  screen(pathname: string): void { /* 全アダプターに画面遷移を通知 */ }
  identify(userInfo: UserInfo, properties?: UserProperties): void { /* 全アダプターにユーザー情報を設定 */ }
}

いくつか設計上のポイントがあります。

  • Fire-and-Forget: track() は同期関数です。各アダプター内部でPromiseが発火しますが、呼び出し側は await しません。行動ログの送信がアプリの操作をブロックすることはありません
  • エラー分離: あるアダプターの送信失敗が他のアダプターに影響しません
  • サービスルーティング: getServicesForEvent() でイベントごとに送信先を制御できます。現時点ではすべてのイベントをFirebase・Clarityの両方に送信していますが、将来的に「このイベントはFirebaseだけ」といった制御を加えやすい構造です
// infrastructures/analytics/core/registry.ts
export function getServicesForEvent(_event: AnalyticsEvent): readonly ServiceName[] {
  // デフォルトは全サービスに送信
  // 特定のイベントのみ送信先を制限する場合はここで定義
  return ["firebase", "clarity"]
}

グローバルインスタンスは、シングルトンパターンで管理しています。アプリ起動時に initializeAnalytics() で初期化し、以降は getAnalytics() でインスタンスを取得します。万が一、初期化前に呼ばれた場合は自動初期化のフォールバックがあります。

let globalAnalytics: Analytics | null = null

export function initializeAnalytics(): Analytics {
  if (globalAnalytics) {
    console.warn("[Analytics] Already initialized.")
    return globalAnalytics
  }
  const analytics = new Analytics()
  analytics.registerAdapter(new FirebaseAdapter())
  analytics.registerAdapter(new ClarityAdapter())
  globalAnalytics = analytics
  return analytics
}

export function getAnalytics(): Analytics {
  if (!globalAnalytics) {
    console.warn("[Analytics] Auto-initializing.")
    return initializeAnalytics()
  }
  return globalAnalytics
}

hooks

コンポーネントからは useAnalytics フック経由でイベントを送信します。

// hooks/useAnalytics.ts
export const useAnalytics = () => {
  const track = useCallback((event: AnalyticsEvent) => {
    getAnalytics().track(event)
  }, [])

  const screen = useCallback((pathname: string) => {
    getAnalytics().screen(pathname)
  }, [])

  const identify = useCallback((userInfo: UserInfo, properties?: UserProperties) => {
    getAnalytics().identify(userInfo, properties)
  }, [])

  return { track, screen, identify }
}

Reactでグローバルな機能をコンポーネントに渡す場合、通常は Context + Provider を使います。しかし、Analyticsインスタンスは「アプリ起動時に一度だけ初期化され、その後入れ替わることがない」ため、Reactのリアクティブな仕組み(値が変わったら再レンダリング)に乗せる必要はありません。getAnalytics() というただの関数呼び出しでインスタンスを取得できるので、Providerのネストも不要です。依存配列が空の useCallback で安定した関数参照を返します。

実際の使い方はこうなります。

const { track } = useAnalytics()

// 型安全なイベント送信
track({
  event_name: "sign_up_done",
  method: "email"           // ✅ 型チェックが効く
})

track({
  event_name: "sign_up_done",
  method: "invalid"          // ❌ コンパイルエラー
})

event_name を書いた時点でTypeScriptがイベント型を特定するため、そのイベントに必要なパラメータとその型が自動的に決まります。存在しないイベント名や不正なパラメータはコンパイルエラーになります。IDEの補完も効くので、「このイベントにはどんなパラメータが必要だっけ」と調べる手間がなくなります。

この設計で良かったこと

型で厳密に縛られている

130以上のイベントすべてで、イベント名とパラメータの整合性がコンパイル時にチェックされます。IDEの補完が充実するため、イベント定義を調べる必要はありません。イベント定義を変更した場合も、型エラーが影響範囲を教えてくれるため、リファクタリングの安全性が高いです。

人が読みやすいフォーマット

YAML形式なので、エンジニア以外のメンバーもイベント定義を読めます。各イベントに description フィールドで意図を日本語で記述しているため、「このイベントは何を意味するのか」が明確です。

バリデーションを自然に組み込める

コード生成という中間ステップがあることで、バリデーションを挟む余地が生まれます。TypeScriptで直接型を定義するアプローチでは、ビルド時に「このイベント名はGA4の40文字制限を超えている」といったチェックを入れるのは難しいですが、生成スクリプトがあればそこにロジックを追加するだけです。

GA4は規約に沿わないデータを自動で除外する仕組みなので、後から原因を探すのが少し大変な面があります。最初の定義段階でルールを整えておくと、後々の調査コストを抑えられて、みんなの運用がぐっと楽になります。

拡張しやすい

アダプターを1つ実装するだけで新しいアナリティクスサービスに対応でき、生成スクリプトにロジックを足すだけで新しいバリデーションルールの追加が可能になり、YAML定義にフィールドを足すだけでメタデータを拡張できます。それぞれの拡張が独立しているため、変更の影響範囲が小さく抑えられます。

この設計の注意点

生成スクリプトの保守

YAML→TypeScriptの生成ロジックは、一度作ったらそこまで頻繁に変更するものではありません。ただし、その分触る機会が少なく、チーム内で理解している人が限られがちです。とはいえ、生成スクリプト自体は200行程度で複雑ではないため、このコストは許容範囲と判断しています。

イベント使用箇所の追跡

YAMLファイルだけを見ても、どのイベントがコード内のどこで使われているかはわかりません。IDEの「この型を参照している箇所をすべて表示」機能を使えば追跡できますが、イベント定義と使用箇所が物理的に離れているため、コードリーディングの際に行き来する必要があります。とはいえ、イベント定義と使用箇所が同じファイルにある場合でも、イベント名の文字列リテラルで参照するアプローチでは同様の問題があるため、この設計特有のデメリットというわけではありません。

codegen の実行忘れ

YAMLを変更した後に npm run codegen:analytics を実行し忘れると、型定義が古いままになります。CIで生成ファイルの差分をチェックすることで緩和はできますが、開発フローへの組み込みは今後も改善していきたいポイントです。

最後に

行動ログの設計は、「とりあえずイベントを送る」だけなら簡単です。しかし、規模が大きくなると管理の複雑さが急激に増します。YAMLによる一元管理とTypeScript自動生成を組み合わせることで、型安全性・可読性・バリデーションを同時に実現できました。

特に「生成ステップがあるからこそバリデーションを入れられる」という点は、TypeScriptで直接型を書くアプローチでは得にくいメリットです。GA4の制約違反を開発時に検出できるのは、データが来ないことに後から気づくストレスを大幅に軽減してくれます。

まだ改善の余地はありますが、130以上のイベントを安全に管理できている現状には満足しています。同じような課題を抱えている方の参考になれば幸いです。