ABEMA ネイティブチームの石川(@rinov)です。

今回はABEMAで2年運用しているアプリ行動ログの自動生成システムについて紹介します。

 

ネイティブチームはアジャイル開発を取り入れ、現在では週に1度のリリースサイクルで機能の改善や追加をしています。

 

このように日々変化がある中で、施策が狙い通りの結果になっているか、改善した部分がしっかりとKPIにも反映されているかを確かめるために、ユーザーの行動ログを収集し、分析に活用しています。

 

目次

    1. アプリ行動ログとは
    2. 行動ログの課題
    3. 課題の解決 (自動化)
    4. おわりに

 

アプリ行動ログとは

アプリ行動ログとは、サービスがどのように利用されているかを把握するために、ユーザーの操作や端末の状態などを集計し、ビジネス分析やサービスの改善・向上に役立てるためのソリューションになります。

ABEMAでは分析にGoogle Analytics for Firebase (以下、GA)や独自ログ基盤を利用し、行動ログの管理にはGoogleTagManager(以下、GTM)を利用しています。

 

行動ログの課題

日々膨大な量の行動ログがアプリから集計され、その行動ログ自体の定義もサービスの開発に合わせて常に変化していきます。

ABEMAではデータマネジメントチームというデータ分析やログの管理を担うチームがあり、データマネジメントチームでログの定義が更新され、それに伴いアプリ側のログ実装に反映させていくフローがありました。

しかし、ログ定義から実装としてエンジニアが手入力することや、そのパラメータ数が多いことから以下のような課題がありました。

 

  • 小さなタイプミスなどが分析に影響を及ぼしてしまうこと
  • ログ設計が更新されたにも関わらずアプリ側に反映されないままの可能性があること

 

このような課題から、ネイティブチームではデータマネジメントと連携し、課題のリスクを減らし、より運用負荷の低いオペレーションにできる方法を模索しました。

 

課題の解決 (自動化)

行動ログの課題を踏まえ、ネイティブチーム ではこれら行動ログをアプリ側で型安全に適切な値を設定でき、自動更新される状態を目指した運用フローの構築を進めました。

 

自動化するにあたり、ABEMAでは行動ログをGTMで管理していますが、GTMはメタ情報を付与する仕組みがないため、そのパラメータが数値なのか文字列なのかや、なにが正しい値かについては定義することができず、課題であったタイプミスの削減がそのままではできない状況でした。そのため、GTMの情報に加えて期待すべき値をスプレッドシートからドキュメント管理に利用していたConfluenceに移行し、API経由でConfluenceに記載された属性や値とGTMのログ定義を取得し、JSON化して管理するリポジトリをまず追加しました。

 

今回は元々ドキュメント管理に利用していたConfluenceを書式設定済みフォーマットなどを組み合わせ、API経由でも比較的安全に値を取得できたため利用していますが、こちらに関しては組織に応じた様々なソリューションもあると考えています。

 

また、GTMではアプリ側にバンドルされるコンテナ情報の入ったデフォルトコンテナというJSONファイルが存在します。デフォルトコンテナはアプリインストール時に行動ログの初期定義をするために利用され、以降はインターネット接続がある場合に12時間毎にコンテナの最新情報がSDK側で定期的に確認されるようになります。

このデフォルトコンテナも定期的にアプリ側にバンドルする必要があるため、CI上で以下のように定期的に差分確認を行い、コンテナバージョンが更新されている場合には自動でPullRequestを作成する対応も追加しました。

 

import os
import requests
import json
from dataclasses import dataclass


@dataclass
class Project:
    id: str
    platform: str
    container_dir_path: str

    def get_download_url(self):
        return f"https://www.google-analytics.com/gtm/{self.platform}?id={self.id}"

    def get_container_file_path(self):
        return self.container_dir_path + f"/{self.id}.json"
        
        
def get_current_version(project):
    with open(project.get_container_file_path(), "r") as f:
        j = json.load(f)
    return int(j["resource"]["version"])

def update_container_if_needed(project):
    headers = {
        'Accept': 'application/json',
    }

    response = requests.get(project.get_download_url(), headers=headers)

    if response.status_code != 200:
        exit(1)

    contents = response.text
    contents_json = json.loads(contents)
    save_path = project.get_container_file_path()

    latest_version = int(contents_json["resource"]["version"])
    current_version = get_current_version(project)
    
    if latest_version > current_version:
        # Checkout to working branch
        branch = f"auto-update-container-{latest_version}"
        os.system(f"git checkout -b {branch}")

        with open(save_path, "w") as f:
            f.write(contents)

        # Create a new draft PR
        os.system("git commit -am \"ci: update gtm container file\"")
        os.system(f"git push -u origin {branch}")
        os.system("echo \"Update GTM container\" | hub pull-request --labels automerge -F -")

...
main scopeで以下を呼び出すscriptをCI上で定期スケジュールする

ios = Project(id="GTM-xxxxxxx", platform="ios", container_dir_path="container")
update_container_if_needed(ios)

 

続いて本題となる行動ログの情報を型安全に適切な値を設定する方法についてですが、前述までにGTMとConfluenceの情報をもとにログ情報をJSON化したリポジトリを作成したため、これを使い各プラットフォーム毎のプログラミング言語に落とし込んでいきます。

 

そこでまず、既存のJSONファイルをJSON Schemaに変換することを考えます。
JSON Schemaとは、JSONデータの構造そのものをJSONで定義するためのものであり、JSON Schemaで管理すると以下のメリットなどが挙げられます。

 

  • 型情報のサポートがされていること
  • データのバリデーション機能があること
  • 各種プログラミング言語に変換ができること

 

今回は上記の機能をサポートしている、かつ、ネイティブチームが利用しているSwift/Kotlinに変換ができるOSSとして以下を利用しました。

 

 

これを利用し、ログ設計から構造化したJSON Schemaを作成し任意のプログラミング言語に変換する以下のフローを構築しました。

 

行動ログ自動生成の流れ

 

SwiftやKotlinなどのコードをプラットフォーム毎に生成し、アプリ側でライブラリとして取り込むことで型安全なログ実装を実現できるようになります。

 

そして、従来までの行動ログの実装までの流れと比較するとエンジニアが定義を入力することがなく、ログの定義は定期的に自動更新されるためエンジニアは実装するタイミングでライブラリを更新するだけで実装に着手することができるようになりました。

 

 

これにより行動ログの課題であったタイプミスや実装漏れのリスクを自動化によって低減でき、また、運用体制としても実装依頼の際には事前にGTMやConfluenceに定義を更新した状態で依頼が来る運用フローを構築したことで、実装依頼が来た時点でログ定義のコードが生成出来る状態となっているため開発速度が向上しました。

 

最後に、ここではiOSの実際に自動生成されたSwiftとその応用について紹介します。

 

まずこちらがJSON Schemaからquicktypeで作成された生コードになります。

 

public struct PageviewSearchResult: Codable {
    public let event: String
    public let hasSearchResult: Bool
    public let searchMethod: SearchMethod?
    public let searchQuery: String?

    enum CodingKeys: String, CodingKey {
        case event = "event"
        case hasSearchResult = "has_search_result"
        case searchMethod = "search_method"
        case searchQuery = "search_query"
    }

    public init(
        event: String, hasSearchResult: Bool, 
        searchMethod: SearchMethod?, searchQuery: String?
    ) {
        self.event = event
        self.hasSearchResult = hasSearchResult
        self.searchMethod = searchMethod
        self.searchQuery = searchQuery
    }
}

public enum SearchMethod: String, Codable {
    case direct = "direct"
    case history = "history"
    case suggest = "suggest"
    case trends = "trends"
}

 

この段階ですでに十分利用できますが、今回はこのコード自体を中間表現としてここからさらにSwiftコードを生成します。
このコード自体をプログラミングによって生成する手法はメタプログラミングと呼ばれ、今回はSourceryというOSSを利用しています。

 

この工程を入れることにより、より高位な表現を実現することができます。具体的には、これらのログ実装はFirebase経由で値を送信することになりますが、集計の制約上、最終的には全て文字列として扱われることとなります。

つまり、実装上では型安全に様々な型を利用できる一方で、送信する際には全て文字列であることが望ましいということです。

 

ここでは、さきに挙げたPageviewSearchResultを例にメタプログラミングを取り入れるメリットについて解説します。

PageviewSearchResultにはBoolのhasSearchResultという変数があり、ログ実装する上では自然な変数宣言といえます。しかし、先ほどの制約を思い出すと最終的には全てが文字列として集計上には現れるためBoolのままではどういった文字列として集計に現れるかを保証することはできません。そこでよくあるケースとしては、ログの送信レイヤーで以下のような変換をかけるというものです。

 

let boolString = hasSearchResult ? "true" : "false"

 

これにより例えBoolの文字列としての解釈がSwift側で変わったとしても影響をうけないコードにできます。
しかし、こういった変換ルールはライブラリを取り込むアプリ側本体に実装するのは本来望ましくありません。また、その他要件としても今回は以下のようなものがありました。

 

  • Optionalな変数がnilの場合は空文字列として扱う
  • 空文字の場合は”(n/a)”として設定する

 

そのためiOSでは独自型とエンコーダーを作成し、エンコード時にこれらが解決されるコードをメタプログラミングで実現しています。

 

public protocol GTMType: Equatable, Encodable {
    associatedtype RawType
    var rawValue: RawType { get }
}

// Definitions for GTM types
public enum GTM {
    public typealias Float = GTM.Double

    public struct String: ExpressibleByStringLiteral, GTMType {
        public typealias RawType = Swift.String
        
        public let rawValue: RawType

        public init(stringLiteral value: RawType) {
            if value.isEmpty {
                self.rawValue = GTMParameter.notAvailable // "(n/a)"
            } else {
                self.rawValue = value
            }
        }

        public init(_ value: RawType?) {
            if let value = value {
                if value.isEmpty {
                    self.rawValue = GTMParameter.notAvailable
                } else {
                    self.rawValue = value
                }
            } else {
                self.rawValue = GTMParameter.notAvailable
            }
        }

        public func encode(to encoder: Encoder) throws {
            var container = encoder.singleValueContainer()
            try container.encode(self.rawValue)
        }
    }

    public struct Bool: ExpressibleByBooleanLiteral, GTMType {
        public typealias RawType = Swift.Bool

        public let rawValue: RawType

        public init(booleanLiteral value: RawType) {
            self.rawValue = value
        }

        public init(_ value: RawType) {
            self.rawValue = value
        }

        public init?(_ value: RawType?) {
            if let value = value {
                self.rawValue = value
            } else {
                return nil
            }
        }

        public func encode(to encoder: Encoder) throws {
            var container = encoder.singleValueContainer()
            try container.encode(self.rawValue ? "true" : "false")
        }
    }
    
    ...

 

これにより、アプリ側では最終的にBoolがどのような文字列としてログ送信されるかなどに関心を持つ必要がなくなります。
そして、最終的に採択されるSourceryで生成されたコードはこちらになります。

 

public struct PageviewSearchResult: Encodable, Equatable {
        public let event: String = "pageview"
        public let hasSearchResult: GTM.Bool
        public let searchMethod: GTM.Optional
        public let searchQuery: GTM.String

        public init(
            hasSearchResult: Bool,
            searchMethod: SearchMethod?,
            searchQuery: String?
        ){
            self.hasSearchResult = GTM.Bool(hasSearchResult)
            self.searchMethod = GTM.Optional(searchMethod)
            self.searchQuery = GTM.String(searchQuery)
        }

        public enum CodingKeys: String, CodingKey {
        case event = "event"
        case hasSearchResult = "has_search_result"
        case searchMethod = "search_method"
        case searchQuery = "search_query"
        }
}

 

変数の型がBoolではなくGTM.Boolになっていることがわかります。
アプリ側ではBoolと同じように扱うことができるため意識することはありませんが、ログを送信する際にencodeされたGTMTypeはその型に応じた適切な文字列を返却するため、ログの実装は以下のようにとても容易になります。

 

let searchResult = PageviewSearchResult(...)
guard let metrics = try? GTMEncoder().encode(object) else { return }

// ログを送信
Analytics.logEvent(metrics)

 

おわりに

今回はABEMAにおけるアプリ行動ログの取り組みについて紹介しました。

現在はiOS・AndroidやTVデバイスでも利用しており、ログの設計フローも以前に比べシステマチックに進められるようになりました。

分析ニーズは常に開発サイクルと一緒に進んでいくため、その中で品質と速度感を落とさずに費用対効果の高い自動化を推進していくことはとても大切だと実感できました。

最後まで読んでいただきありがとうございました。