FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

GA4のカスタムイベントをコードで管理する話

こんにちは。FLINTERSのカレンダー | Advent Calendar 2022 - Qiita 24日目を担当します、宮下です。

はじめに

弊社が開発を手掛けているマンガアプリGANMA!ではサービスの改善やトラブルシューティングなどのためにGoogleアナリティクス4(GA4)を利用してユーザーの行動履歴を収集しています。
特にiOS, Android向けのアプリではGoogle Analytics for Firebaseを利用してイベントを送信しています。
(以下、Googleアナリティクス4とGoogle Analytics for Firebaseを特に区別せずGA4と呼びます。)

GA4では自動的に収集されるイベントの他に、独自のイベントをカスタムイベントによって計測することが出来ます。
例えば、GANMA!ではマンガの原稿ページを閲覧した・広告をタップした・プレミアム機能のLPページを閲覧したなどの情報はビジネス上重要な指標となるためカスタムイベントで送信しています。
また、これらの他にも100を超えるカスタムイベントが定義されています。

これまではカスタムイベントの定義は社内のWikiにまとめており、アプリでGA4の計測のコードを記述する時はイベント名やパラメータを書き写していました。
しかし、タイプミスなどの間違いが発生し、デバッグでも間違いに気づかずにリリースされてしまうことがありました。

GA4の計測の記述はアプリ開発としてはどうしても副次的な作業と思ってしまいがちで、気をつけようとしてもミスが出てしまうものです。
そこで、GA4のカスタムイベントの定義をコードで管理し、ミスを最小限にする工夫を講じました。

作成した機構

次の流れでイベントを管理することにしました。

  • イベントを定義するスキーマを用意する
  • そのスキーマで計測内容を記述する
  • スキーマをもとに計測内容を表現するKotlin, Swiftのクラスを自動生成する
  • 今後追加する計測内容については、上記の自動生成されたクラスの利用を必須とする
  • スキーマをもとに計測内容のドキュメントを自動生成する

イベントを定義するスキーマを用意する

イベントの定義をプログラムで処理する都合上、yamlで定義を記述することにしました。
スキーマは具体例を見たほうが全体像が把握しやすいと思うので、少し長いですが実際に利用しているイベントの1つを記載します。

- description: Reader原稿ページ閲覧
  eventName: view_reader_page
  schemas:
    - version: 1
      releaseiOS: 7.0.2 <= v <= 7.2.1
      releaseAndroid: 7.0.7 <= v <= 7.2.1
      parameters:
        - name: magazine_id
          type: string
          required: true
          detail: マガジンID
        - name: magazine_title
          type: string
          required: true
          detail: マガジンタイトル
        - name: story_id
          type: string
          required: true
          detail: ストーリーID
        - name: story_title
          type: string
          required: true
          detail: ストーリータイトル
        - name: page
          type: int
          required: true
          detail: 何ページ目か
      when: Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。
      detail: ""
    - version: 2
      releaseiOS: 7.2.2 <= v
      releaseAndroid: 7.2.2 <= v
      parameters:
        - name: magazine_id
          type: string
          required: true
          detail: マガジンID
        - name: magazine_title
          type: string
          required: true
          detail: マガジンタイトル
        - name: story_id
          type: string
          required: true
          detail: ストーリーID
        - name: story_title
          type: string
          required: true
          detail: ストーリータイトル
        - name: page
          type: int
          required: true
          detail: 何ページ目か
        - name: page_str
          type: string
          required: true
          detail: 何ページ目か。pageの内容を文字列にしたもので、GA4のカスタムディメンションで利用する。
      when: Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。
      detail: page_str を追加

主なフィールドの説明です。

description はイベントの説明を簡潔に記したものです。この名前は「Reader原稿ページ閲覧のイベントでは…」と口頭や文面でイベントを伝える時に利用します。

eventName はGA4に送信するイベントの名前です。英数字とアンダースコアのみ利用できるなど、GA4上の規則があります。参考:「イベントの命名規則」
また、大文字と小文字が区別されるため、snake_caseを推奨とする運用上のルールを設けています。

schemas の中は、このイベントのパラメータや送信タイミングが微修正されたことが後から履歴が追えるように、配列で定義を記述することにしました。
version はこのイベントにおけるバージョンを1から連番で記載し、 releaseiOS, releaseAndroid ではそれぞれiOS, Androidアプリのどのバージョンでこのイベント定義のバージョンが利用されているかを記述します。

releaseiOS, releaseAndroid のバージョンの指定は文字列で次のように指定できるようにしました。

  • always (全てのバージョンで利用される。イベントのバージョン管理がされていない古いイベントに利用している。)
  • never (利用するバージョンはない。iOS,Androidの片方のみ送信する場合など。)
  • 6.0.0 <= v <= 6.2.0 (範囲指定)
  • v = 6.2.0 (特定のバージョンのみ)
  • 6.0.0 <= v (特定のバージョン以降)
  • v <= 6.2.0 (特定のバージョンまで)
  • undecided <= v (あるバージョン以降だが、どのバージョンになるかは未定)
  • v <= undecided (あるバージョンまでだが、どのバージョンになるかは未定)

なお、アプリのバージョンは枝分かれしない前提で設計しています。(6.0.06.0.1 と、 6.1.06.1.1 が同時に開発されることはないという前提)

parameters には各カスタムパラメータの詳細を記述します。
name には英数字とアンダースコアのみ利用できるなどGA4上の規則があり、snake_caseを推奨とする運用上のルールを設けています。
type は送信する値の型で、GA4がサポートする3種類の型 int(64bit符号付き整数), double(倍精度浮動小数点数), string が利用できます。
また string の機能拡張版として、特定の文字列だけが期待される箇所のために enum を利用できるようにしました。
例えば、マンガのランキングの画面には総合・女性向け作品・男性向け作品・完結作の4つのランキングがあるため、ランキング閲覧のイベントのパラメータには以下のような enum のパラメータを定義します。

parameters: 
  - name: ranking_name
    type: enum
    enum:
      - value: total
        detail: 総合
      - value: female
        detail: 女性
      - value: male
        detail: 男性
      - value: finish
        detail: 完結
    required: true
    detail: ランキング名

このスキーマで一番重要なポイントは、イベントを送信するアプリのバージョンを厳しく管理した部分です。
これまでWikiでイベントを管理していた時はバージョンについては補足情報として簡単に記述していたのみでした。
しかしイベントを分析する立場からすると、正しくデータを読み取ったり誤集計を避けるためにはイベントのバージョンは重要な情報であり、
実際にこのように定義することによってイベントのバージョン管理を徹底することの価値を改めて感じました。

そのスキーマで計測内容を記述する

既存の社内Wikiにあったイベント定義を上記のスキーマに書き直しました。
100以上のイベントがあったので、数千行のyamlのイベント定義が出来上がりましたが、気合いでやれば半日で終わります。

スキーマをもとに計測内容を表現するKotlin, Swiftのクラスを自動生成する

上記スキーマのyamlファイルを読み込み、Kotlin, Swiftで利用できるコードを出力するプログラムを作成しました。

プログラムはScalaで作成し、利用者にはリポジトリをクローンして sbt を利用するか、ビルドしたjarファイルをダウンロードして利用してもらうことにしました。

実行時のコマンドライン引数は次のものを指定するようにしました。

$ sbt run [opts] または $ java -jar firebase-analytics-events-gen-assembly-1.0.0.jar [opts] にて実行する)

usage: [opts]
 -d,--dest <arg>            出力先のファイルのパス。既に存在すれば上書き、なければ新規作成する
 -m,--mode <arg>            モード(kotlin, swift, docから選択)
 -p,--package <arg>         出力ファイルに含めるパッケージ名(kotlinのみ必須)
 -s,--source <arg>          ソースとなるyamlファイルのパス
 -t,--targetVersion <arg>   アプリの特定のバージョン向けのコード生成を行う際に指定する(ex.
                            6.0.0)。指定しなかった場合(またはlatestと指定した場合)最新のスキーマを出力する
                            。(swift, kotlinのみのオプション)

プログラムの概略は、yamlファイルをパースした後、バリデーションを行い、イベント定義を表すクラスに詰め替えた後、コードの文字列を作成してファイルに書き出すといったものです。
実装方法はいくらでもあるので、ここは特に細かく解説しなくても良いかなという感じです。気合いでやれば数日で書ける分量です。
(小ネタですが、コマンドライン引数のパースはApache Commons CLIが便利でした。yamlのパースはcirce-yamlおよびcirceのcirce-genericを利用しました。利用したライブラリはこれだけです。)
バリデーションに失敗した時に、エラー箇所とエラー内容を分かりやすく出力することが重要です。

Kotlinモードの場合で実行した場合、次のようなファイルが生成されるようにしました。

(「イベントを定義するスキーマを用意する」の節のイベント定義を events.yaml に記載し、
java -jar firebase-analytics-events-gen-assembly-2.0.2.jar -m kotlin -s events.yaml -d kotlin/events-7.2.2.kt -t 7.2.2 -p jp.ganma.presentation.analytics.gen にて実行した。
以下、 kotlin/events-7.2.2.kt の内容)

// This code was automatically generated for Android version 7.2.2
// by firebase-analytics-events-gen version 2.0.2

package jp.ganma.presentation.analytics.gen

sealed class Event(
    val name: String,
    val params: List<Parameter>
)

sealed abstract class Value() {
  class VInt(val value: Long) : Value()
  class VDouble(val value: Double) : Value()
  class VString(val value: String) : Value()
}

class Parameter(
    val name: String,
    val value: Value
)


// イベント一覧

/** Reader原稿ページ閲覧 (view_reader_page, version: 2) */
class Event_view_reader_page(
    magazine_id: String,
    magazine_title: String,
    story_id: String,
    story_title: String,
    page: Long,
    page_str: String
) : Event(
  "view_reader_page",
  listOf<Parameter?>(
    Parameter("magazine_id", Value.VString(magazine_id)),
    Parameter("magazine_title", Value.VString(magazine_title)),
    Parameter("story_id", Value.VString(story_id)),
    Parameter("story_title", Value.VString(story_title)),
    Parameter("page", Value.VInt(page)),
    Parameter("page_str", Value.VString(page_str))
  ).filterNotNull()
) {

  // (イベント定義でenumのパラメータを指定した場合は、ここにenum classが作成される)

}

yamlのイベント定義の方では全てのアプリバージョンの定義の履歴を記載していますが、出力されるコードには特定のアプリバージョン用のコードのみ出力するようにしています(ここではバージョン7.2.2用を指定)。

作成したコードはアプリのリポジトリにコピペして利用し、アプリでイベントを送信する箇所でこの生成したクラスを利用します。
イベント名やパラメータ名を手打ちする必要がなく、クラス名やコンストラクタ引数の名前を間違えてもコンパイルエラーとなるため間違いが発生しません。
ただし、コンストラクタ引数の順序を間違えてしまう可能性はあるため、次のように引数の名前付きで値を指定するコーディング規約を設けています。

val event = Event_view_reader_page(
    magazine_id = "00000000-0000-0000-0000-000000000000",
    magazine_title = "マガジンタイトル",
    story_id = "00000000-0000-0000-0000-000000000000",
    story_title = "ストーリータイトル",
    page = 1,
    page_str = "1"
)

このインスタンスをもとに実際にGoogle Analytics for Firebaseに送信するコードは、このイベント生成機構では扱わず、Androidアプリのリポジトリ側で管理するようにしています。
具体的には上記クラスを Bundle 型に変換し、 logEvent() メソッドを利用して送信するコードを別途用意します。(参考

iOSモードで実行した場合も生成されるコードがSwiftであるというだけで、大きな違いはありません。

この機構によってイベント送信時のタイプミスの問題が起こることは無くなりました。
要は型が利用できるようにコードを自動生成する工夫を講じたというだけですが、大きな成果です。

今後追加する計測内容については、上記の自動生成されたクラスの利用を必須とする

これから使っていきましょうと啓蒙するだけです。
「今後追加する計測内容について」と但し書きを書いて弱気な態度をとってしまいましたが、実際には過去のイベント送信部分についても移行が進んだので良かったです。
(また、過去のイベント送信部分で既存のWikiのイベント定義と違っていた箇所も一部見つかり、修正が出来ました。)

スキーマをもとに計測内容のドキュメントを自動生成する

分析に利用する人のために、ドキュメントも自動生成します。
アプリのコードとは違い、ドキュメントの方にはこれまでのイベントのバージョンの履歴を全て出力します。
レイアウトにこだわるのは手間だったので、簡易的にmarkdownの表で出力するようにしました。

以下のようなmarkdownを出力します。

# イベント一覧

| 説明 | イベント名 | バージョン | パラメータ | 計測タイミング | 備考 | iOSリリースバージョン | Androidリリースバージョン |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| Reader原稿ページ閲覧 | view_reader_page | 1 | <ul><li>magazine_id (string, required, マガジンID)</li><li>magazine_title (string, required, マガジンタイトル)</li><li>story_id (string, required, ストーリーID)</li><li>story_title (string, required, ストーリータイトル)</li><li>page (int, required, 何ページ目か)</li></ul> | Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。 |  | 7.0.2 <= v <= 7.2.1 | 7.0.7 <= v <= 7.2.1 |
| Reader原稿ページ閲覧 | view_reader_page | 2 | <ul><li>magazine_id (string, required, マガジンID)</li><li>magazine_title (string, required, マガジンタイトル)</li><li>story_id (string, required, ストーリーID)</li><li>story_title (string, required, ストーリータイトル)</li><li>page (int, required, 何ページ目か)</li><li>page_str (string, required, 何ページ目か。pageの内容を文字列にしたもので、GA4のカスタムディメンションで利用する。)</li></ul> | Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。 | page_str を追加 | 7.2.2 <= v | 7.2.2 <= v |

ツールにもよりますが、このような形で表示できるはずです。

イベント一覧

説明 イベント名 バージョン パラメータ 計測タイミング 備考 iOSリリースバージョン Androidリリースバージョン
Reader原稿ページ閲覧 view_reader_page 1
  • magazine_id (string, required, マガジンID)
  • magazine_title (string, required, マガジンタイトル)
  • story_id (string, required, ストーリーID)
  • story_title (string, required, ストーリータイトル)
  • page (int, required, 何ページ目か)
Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。 7.0.2 <= v <= 7.2.1 7.0.7 <= v <= 7.2.1
Reader原稿ページ閲覧 view_reader_page 2
  • magazine_id (string, required, マガジンID)
  • magazine_title (string, required, マガジンタイトル)
  • story_id (string, required, ストーリーID)
  • story_title (string, required, ストーリータイトル)
  • page (int, required, 何ページ目か)
  • page_str (string, required, 何ページ目か。pageの内容を文字列にしたもので、GA4のカスタムディメンションで利用する。)
Readerの原稿ページ(あとがき・エクスチェンジは含めない)の閲覧を計測する。一枚ずつ送信する。 page_str を追加 7.2.2 <= v 7.2.2 <= v

導入当初はこのバージョンの指定方法は煩雑で利用者が読むのは大変なのではないかと心配していましたが、少し説明をすれば理解して頂けたので良かったです。

まとめ

GA4のカスタムイベントの定義をコードで管理することで、イベント送信時のタイプミスの問題をなくすことが出来ました。
また、スキーマでイベントのバージョン管理を厳しく管理することで、分析者に便利なドキュメントを作成することができるようになりました。
この機構を用意したコストに対してリターンは十分に得られたと思います。
皆様ももし似たような問題を抱えていていたら、是非このアイデアを取り入れてみてください。