AWS 内で大規模言語モデルを利用できる Amazon Bedrock を使って作る RAG アプリケーション

こんにちは。機械学習グループの深澤(@fukkaa1225)です。

先日、Amazon Bedrock が一般利用できるよう(GA)になりました 。本記事ではこちらを用いて RAG(Retrieval-augmented generation) アプリケーションを作成してみた様子と、他 LLM モデルとの比較結果についてご紹介します。

Amazon Bedrock とは

aws.amazon.com

公式サイトより文言を引用します。

Amazon Bedrock は、Amazon や主要な AI スタートアップ企業が提供する基盤モデル (FM) を API を通じて利用できるようにする完全マネージド型サービスです。そのため、さまざまな FM から選択して、ユースケースに最も適したモデルを見つけることができます。Amazon Bedrock のサーバーレスエクスペリエンスにより、すぐに FM を開始したり、FM を簡単に試したり、独自のデータを使用して FM をプライベートにカスタマイズしたり、AWS のツールや機能を使用して FM をアプリケーションにシームレスに統合してデプロイしたりできます。Amazon Bedrock のエージェントはフルマネージド型で、デベロッパーは独自の知識源に基づいて最新の回答を提供し、幅広いユースケースのタスクを完了できる生成系 AI アプリケーションを簡単に作成できます。

殆どの企業にとって、現時点で LLM を使うときには OpenAI が提供する GPT-3.5-turbo, GPT-4 を使うことがほぼ唯一の選択肢になっているかと思います。弊社も GPT シリーズの API を活用して社内版 ChatGPT を展開しています。 一方で、OpenAI を用いる上でいくつか考えなくてはならない問題もあります。例えば、権限管理が API Key によってなされているため取り扱いに注意する必要があったり、OpenAI と通信する必要がある以上セキュリティ要件を満たせないケースがあるなどの点が挙げられます。 これに対して Amazon Bedrock は AWS のサービスであるため IAM での権限管理が可能です。通信も AWS 内で完結しており、VPC と接続できるのも嬉しいポイントです。モデルについても、GPT シリーズに匹敵する十分な性能を持ったものが用意されています。

Claude とは

Amazon Bedrock で利用できるモデルはいくつかありますが、日本語での質問応答に適したものとなると実質的に使えるモデルは Claude シリーズです。 Claude シリーズは Anthropic が提供しているモデルです。Chatbot-arena・Nejumi JGLUE スコアリーダーボード などいくつかのベンチマークで Claude シリーズの能力は GPT シリーズに匹敵するスコアを出しています。

wandb.ai

Amazon Bedrock では Playground で Claude シリーズとのチャットを試すこともできます。試してみると、想像以上に流暢な日本語で喋ってくれることがわかります。下図のように夕飯の献立を提案してくれました。

Claude シリーズの優れている点として、入力できるトークン数の多さと価格の安さが挙げられます。Claude シリーズに入力可能なトークン数は脅威の 10万トークンで、これは GPT-4 の 8192 トークンと比較すると圧倒的な数字です。 また、価格も GPT-4 と比べると非常に安いです。2023/10/12 時点で Build Generative AI Applications with Foundation Models - Amazon Bedrock Pricing - AWS を見ると Claude 2 の値段は掲載されていないため、 Claude の値段で比較を行います。単位はドルです。

Model Input Output
Claude(100k) 0.011 0.032
GPT-4(8k) 0.03 0.06
GPT-4(32k) 0.06 0.12
GPT-3.5-turbo(4k) 0.0015 0.002
GPT-3.5-turbo(16k) 0.003 0.004

Claude と GPT-4 のみの比較で言えば、Claude は 1/3 以下の値段となっています。それでいてベンチマーク上ではかなり良い勝負をしているので、非常に優れた選択肢であると言えるでしょう。GPT-3.5-turbo と比較してしまうと GPT-3.5-turbo の安さが際立ちます。トークン数や応答の質に関して満足できるならばやはり GPT-3.5-turbo は有力な選択肢ではあります。 一方で 10万トークンを実現しながら AWS 内で通信を完結できる十分な性能を持った LLM を用いることができる Bedrock はそれだけで十分なメリットを持っていると思います。

社内文書に対する RAG アプリケーションを作成する

Cookpad には Groupad と呼ばれる社内 wiki のようなものがあります。かなり長年運用されており、様々な知見が蓄積されていて日々の仕事を助けてくれています。 一方で問題もあり、Groupad に対する検索システムはあまりチューニングを行っていないため、同義語解決などがされず、ほしい結果を得るのに苦労することがありました。 そこで、ユーザーからのクエリに基づいて外部データから関連するドキュメントを検索し、検索結果を prompt に埋め込んで LLM に結果を生成させて表示するアプリケーション (Retrieval-augmented generation、RAG) を作成することにしました。 LLM を用いて semantics を考慮したベクトル検索と質問応答を実装することで、チューニングの手間なく今よりも幅広い検索結果を得られるだろうと考えました。

以下、実際に作ってみた様子をご紹介します。 RAG アプリケーションを作るために必要なコンポーネントはいくつか存在します。代表的なものとしては以下のようなものかと思います。

  • Vector DB ... RAG のソースとなる情報(ここでは Groupad の文書群)をベクトルとして保持しておくための DB
    • Embedding Function ... ベクトル DB に文書を追加する際に文書をベクトルに変換する
    • Retriever ... ベクトル DB から検索クエリにマッチする文書を取得する
  • LLM ... クエリから得られた関連文書を元に、LLM による対話応答を行う

この内、LLM は Amazon Bedrock で使うことができる Claude 2 を用います。 Vector DB は Chroma DB を用いることにします。 簡単な構成図としては以下のようなものになります。

なお、Bedrock には知識ベースと接続して質問応答を行うアプリケーションを作るための機能が存在します。検索拡張生成 (RAG) - Amazon Bedrock のナレッジベース - AWS この場合 Embedding には Bedrock が提供する Amazon Titan を使うことになります。ですが、Amazon Titan は現時点では英語にのみ対応したモデルで、日本語のテキストに対する埋め込み表現を得るのに適したモデルとなっていないため、今回は自分たちで huggingface hub から適したモデルをダウンロードして使うこととします。

Vector DB

embedding function には oshizo/sbert-jsnli-luke-japanese-base-lite を利用しました。これを用いて Groupad の文書群をベクトル化し、 Chroma DB に保存します。 ベクトル検索をするだけのクライアントを用意するなら例えば以下のようなものが考えられるかと思います。

class VectorSearcher:
    def __init__(self, db_path: str) -> None:
        embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="oshizo/sbert-jsnli-luke-japanese-base-lite")
        client = chromadb.PersistentClient(path=db_path)
        self.collection = client.create_collection("groupad", embedding_function=embedding_function)

    def set_groupad_data(self, data_path: str) -> None:
        logger.info("set groupad data")
        # columns: id, title, content, created_at
        df = pd.read_csv(data_path)
        self.collection.add(
            ids=df["id"].apply(str).values.tolist(),
            documents=df["content"].values.tolist(),
            metadatas=[{"title": title, "created_at": created_at} for title, created_at in zip(df["title"].values, df["created_at"].values)],
        )
        logger.info("set done")

    def search(self, query: str, top_k: int = 10) -> list[tuple[str, str]]:
        res = self.collection.query(query_texts=[query], n_results=top_k)

        return res

今回は RAG を LangChain を用いて実装するため、実際にはこのベクトル検索クラスは用いません。

LLM: Claude 2

では続いて、今回の目玉である Claude 2 を Amazon Bedrock から利用する設定をします。

Bedrock の client はシンプルに import boto3; client = boto3.client('bedrock') で使えるようになっています。Bedrock - Boto3 1.28.66 documentation

LangChain に Bedrock を使えるオプションが存在するので、今回はVector DB と接続する部分をそれに頼ることにします。 以下のように簡単に書けますので、あとはいつもの OpenAI などを使うときと同じように使えるはずです。

from langchain.llms import Bedrock

llm = Bedrock(
    credentials_profile_name="bedrock",
    model_id="anthropic.claude-v2",
    model_kwargs={
        "max_tokens_to_sample": 1000
    }
)

RAG 全体像

ChromaDB の設定などをすべて LangChain 上で行うようにして、以下のようにすれば RAG を実装できます。

なお ChromaDB の設定上、sqlite のバージョンが 3.35.5 以上でないといけないため、事前に sqlite の設定が必要な場合があります。 (ビルドして export LD_LIBRARY_PATH=sqlite-3.42.0/.libs を指定するなど)

from langchain.chains import RetrievalQA
from langchain.document_loaders.csv_loader import CSVLoader
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.llms import Bedrock
from langchain.vectorstores import Chroma


def build_qa_chain(file_path: str) -> RetrievalQA:
    loader = CSVLoader(file_path=file_path, source_column="content")
    data = loader.load_and_split()

    embeddings = HuggingFaceEmbeddings(
        model_name="oshizo/sbert-jsnli-luke-japanese-base-lite"
    )
    vectorstore = Chroma.from_documents(documents=data, embedding=embeddings)
    llm = Bedrock(
        credentials_profile_name="bedrock",
        model_id="anthropic.claude-v2",
        model_kwargs={"max_tokens_to_sample": 1000},
    )

    qa_chain = RetrievalQA.from_chain_type(llm, retriever=vectorstore.as_retriever())

    return qa_chain

実際に使うときは以下のようにして利用することができます。

qa_chain({"query": "ほげほげ"})

実際の出力を見てみた

さて、ここまで RAG の実装方針について示してきました。社内ドキュメントを対象とした実際の例をご紹介することはできませんが、今回は代替データとして深澤が Techlife で公開した記事をソースとして、RAG の挙動を確かめることとします。

今回のコードでは content カラムだけを取り込むようにしているので、以下の記事の markdown を持ってきて idx,content カラムの形式で csv "data/fukasawa_techlife.csv" に保存します。

qa_chain = build_qa_chain("data/fukasawa_techlife.csv") として qa_chain を取り出せば質問応答を行うパイプラインが用意できます。 出力を見てみましょう。

In [4]: print(qa_chain({"query": "深澤さんが取り組んできたレコメンドについて教えてください"}))
{'query': '深澤さんが取り組んできたレコメンドについて教えてください',
'result': '''
では、深澤さんが取り組んできたクックパッドマートのレコメンドについて要点をまとめます。

- 最初はItem2Vecでレコメンデーションを実装していましたが、オフラインの評価指標が思わしくなかったため他の手法を探していました
- そこでRecBoleを利用して30モデル以上の実験を行った結果、RecVAEが良好な指標を示しました
- RecVAEとItem2Vecをオンラインで比較するため、インターリービングによるテストを実施しました
- その結果、RecVAEの方が指標的に良く、Item2VecからRecVAEへの移行を決定しました

深澤さんはItem2Vecから始まり、RecBoleを利用した実験を重ね、最終的にRecVAEへ移行したことが分かります。レコメンデーション精度の改善に向けた断続的な取り組みが伺えます。
'''}

質問応答としてはかなり完璧ですね。クックパッドマートにおける item-to-item レコメンデーションの変遷 - クックパッド開発者ブログ での内容です。

In [5]: print(qa_chain({"query": "深澤さんが取り組んできた仕事について教えてください"}))
{'query': '深澤さんが取り組んできた仕事について教えてください',
'result': '''
はい、深澤さんの仕事についてまとめてみました。

- クックパッドに入社後、主に自然言語処理を活用したプロジェクトを担当されています。
- NPSアンケートの自動分類システムを構築したり、本記事で紹介されているように、NPSコメントのポジティブ/ネガティブ部分の抽出システムの開発に取り組んでいます。
- 料理手順の自動生成プロジェクトにも関わっていて、手順間の照応解析や手順から材料を自動抽出する研究などを行っています。
- Google BigQueryを活用した大規模データ分析も行っており、レシピ閲覧ログの分析結果を基にレシピ検索の改善を図るなどしています。
- 推薦システムの研究開発にも携わっており、Matrix Factorizationや popularity-based モデルなどを用いたレコメンデーションエンジンの開発をしています。
- 卒業論文ではソーシャルメディアテキスト(Twitter)のセンチメント分析を研究していました。
- 論文執筆や学会発表も積極的に行っており、自然言語処理分野を中心に研究開発を進めています。

以上のように、クックパッドにおける深澤さんの仕事は、自然言語処理とデータ分析を組み合わせた研究開発が中心だと言えそうです。
'''}

料理手順の自動生成プロジェクトソーシャルメディアテキスト(Twitter)のセンチメント分析 はしていないので hallucination が見られますが、大体の雰囲気はあっています。ちょっと範囲の広い聞き方をしたのが良くなかったのかもしれません。

In [6]: print(qa_chain({"query": "深澤さんはインターンシップにもかかわっていたことがあるのですか?"}))
{'query': '深澤さんはインターンシップにもかかわっていたことがあるのですか?',
'result': '''
はい、深澤さんはクックパッドでインターンシップにかかわっていました。

深澤さんはクックパッドの研究開発部に所属していて、過去にサマーインターンシップのメンターを務めた経験があるようです。
例えば、このブログ記事では2020年のサマーインターンシップで深澤さんがメンターを務めた様子が書かれています。
https://techlife.cookpad.com/entry/2020/09/14/140000

この記事によると、深澤さんは機械学習コースのメンターとして、インターン学生の技術指導を行ったそうです。また、インターンシップのプログラム作成にも関わったとのことです。他にも、深澤さんは過去のインターンシップで機械学習の講義を行ったり、インターン学生の研究開発にアドバイスをしたりするなど、インターンシップに積極的に関わってきたようです。そのため、深澤さんはインターンシップにかかわった経験が豊富で、インターン学生の育成に力を入れているエンジニアのようです。
'''}

回答してきた URL や 機械学習コースのメンターとして、インターン学生の技術指導 というあたりもまた hallucination が起きています。一方で事実も存在しており、インターンシップのプログラム作成にも関わった という部分などは事実です。

GPT-4 との回答比較

claude-v2 には hallucination が見られましたが、GPT-4 ではどうでしょうか。出力を比較するためにコードに以下の変更を加えます:

  • llm の部分を langchain.chat_models.ChatOpenAI を用いるようにする
  • GPT-4 の Max Token Size 8192 に合わせて langchain.text_splitter.RecursiveCharacterTextSplitter や max_token パラメータの調整を行う

最終的には以下のようなコードで実行しました。

def build_qa_chain2(file_path: str) -> RetrievalQA:
    loader = CSVLoader(file_path=file_path, source_column="content")
    data = loader.load_and_split(RecursiveCharacterTextSplitter(chunk_size=2048, chunk_overlap=0))

    embeddings = HuggingFaceEmbeddings(
        model_name="oshizo/sbert-jsnli-luke-japanese-base-lite"
    )
    vectorstore = Chroma.from_documents(documents=data, embedding=embeddings)
    llm = ChatOpenAI(
        model_name="gpt-4",
        temperature=0,
        max_tokens=4096
    )

    qa_chain = RetrievalQA.from_chain_type(
        llm,
        retriever=vectorstore.as_retriever(),
        chain_type="stuff"
    )

    return qa_chain

Claude 2 に対して行ったのと同じ質問を投げてみた結果、以下のような結果を得られました。

In [71]: print(qa_chain({"query": "深澤さんが取り組んできたレコメンドについて教えてください"}))
{'query': '深澤さんが取り組んできたレコメンドについて教えてください', 'result': '深澤さんは、レコメンド系のプロジェクトRecBoleに取り組んでいます。RecBoleは再現性に取り組むプロジェクトで、50以上のレコメンドモデルを一つのコマンドで試すことができます。深澤さんは、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、これらのレコメンドモデルを試す実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できました。'}

In [72]: print(qa_chain({"query": "深澤さんが取り組んできた仕事について教えてください"}))
{'query': '深澤さんが取り組んできた仕事について教えてください', 'result': '深澤さんはクックパッドの研究開発部で働いており、レコメンド系のプロジェクトRecBoleに注目しています。RecBoleを用いて、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、50以上のレコメンドモデルの実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できたとのことです。'}

In [73]: print(qa_chain({"query": "深澤さんはインターンシップにもかかわっていたことがあるのですか?"}))
{'query': '深澤さんはインターンシップにもかかわっていたことがあるのですか?', 'result': '文脈からは深澤さんがインターンシップに関与していたかどうかは明らかになりません。'}

GPT-4 はレコメンドと仕事に関する質問については Claude 2 と同じような返答をしています。一方、インターンシップの話題に関してはうまく見つけられなかったのか答えられない、という返答になりました。hallucination を抑えるような学習の結果として、このように外れではない無難な回答に終始しているのかもしれません。

Claude 2 は context window が大きい分長い系列を処理できますが、長距離依存をうまく扱えず hallucination が起こった可能性が考えられます。そうだとすれば max_token パラメータを調整することで hallucination を抑制できたかもしれません。

LLM のこうした傾向の違いについてはまだあまり調査できていないため、今後比較検討できればと思っています。

まとめ

Amazon Bedrock が提供している Claude 2 と LangChain を組み合わせて RAG アプリケーションを作成してみました。 Claude 2 は OpenAI のモデルと比べても性能やコストの点で魅力であり、これを AWS のサービスとして用いることができるのは非常に便利だなと感じました。 Amazon Bedrock の到来によって社内ドキュメント に対する RAG アプリケーションを作るハードルはかなり下がったと感じています。積極的に社内で試して、ゆくゆくはユーザの方にも使っていただけるような LLM アプリケーションを作る際の知見をためていきたいです。

この記事を読んでいただきありがとうございました。 クックパッド機械学習グループでは引き続き最先端の機械学習の技術をプロダクトで活かすべく、試行錯誤と開発を進めていきます。

Hatamoto 〜モバイルアプリに関する情報を一元管理するためのWebアプリケーション〜

こんにちは。元モバイル基盤部(現クックパッドマートプロダクト開発部)の大川(@aomathwift)です。

クックパッドでは、レシピサービスのクックパッドアプリだけでなく、生鮮 EC サービスのクックパッドマートをはじめ、複数の iOS アプリを開発しています。

複数のアプリを開発する上で、機能そのものの開発以外に開発者が気にかけないといけないことがいくつかあります。 例えば、各種証明書の有効期限、ライブラリの選定やバージョンアップなどです。開発者はこれらの関心事に対して、全てのアプリで同じように注意していなければいけません。

クックパッドでは、「Hatamoto」という Web アプリケーションでこれらのアプリに関する情報を一元管理しています。 本記事では、iOS アプリにまつわる情報管理の中で生じる課題と、Hatamoto がそれをどのように解決しているかを紹介します。

Hatamoto 導入前の課題

クックパッドでは、モバイル基盤というグループが App Store Connect をはじめとするアプリ開発に必要なサービスのワークスペースの管理者としての役割を担っています。アプリの証明書の発行等のオペレーションは、モバイル基盤グループで行っています。

このモバイル基盤グループに所属するエンジニア目線で、Hatamoto 導入前は社内アプリの証明書管理やライブラリの更新に関していくつかの課題がありました。

開発者が自発的に証明書の有効期限に気づく必要がある

iOSアプリでプッシュ通知を送信するには、Apple Push Notification Service(以下 APNs )の証明書が必要です。この証明書は1年という有効期限を持つため、継続的にプッシュ通知を送り続けるためには期限が切れる前に更新して差し替える必要があります。 もしこの証明書の差し替えを忘れて有効期限を過ぎると、アプリのプッシュ通知が突然届かなくなってしまいます。 そのため、アプリ開発者達は APNs 証明書の有効期限を把握し、期限が切れる前に自発的にモバイル基盤グループに更新を依頼する必要がありました。

また、クックパッドでは、Apple Developer Enterprise Program に加入しており、社内で動作確認するためのアプリを配信するのに利用しています。この Apple Developer Enterprise Program を使ってアプリを配信する際にも、通常の Apple Developer Program を使った配信と同様に Provisioning Profile が必要になります。 もしこの Provisioning Profile の更新を忘れて有効期限を過ぎると、社内ですでに配信済みだったアプリが突然利用できなくなるという、開発を進める上での問題が起こります。さらに、通常の Apple Developer Program で作成した App Store 配信用の Provisioning Profile も、更新を忘れると App Store にアップロードするバイナリをビルドできない問題に直面します。 したがって、これらも APNs 証明書と同様に、期限が切れる前に気づいて更新を依頼する必要がありました。

更新が必要なライブラリを使い続けていることに気づけない

クックパッドで開発している iOS アプリでは、オープンソースのライブラリから社内ライブラリまで、様々なライブラリを利用しています。 モバイル基盤グループでは、あるライブラリで脆弱性を含んだバージョンがリリースされたり、既存の挙動を壊すような問題・変更のあるバージョンがリリースされたりした場合に、そのライブラリを使用しているアプリの開発者に迅速に共有したいという需要がありました。*1

しかし、各アプリで使われているライブラリとそのバージョンを常に人力で追い続けることは難しく、利用しているアプリ側で実際に問題に直面してから対処するということが多いのが実情でした。

Hatamotoの機能

上記の課題の解決をモチベーションに、Hatamoto には以下の機能が実装されています。

証明書の有効期限を監視し、期日が近づいたら Issue を起票する

各アプリの APNs 証明書に加え、Provisioning Profile ・開発者証明書の有効期限を一覧して管理できます。

また、この収集した有効期限を毎日定期実行されるジョブで監視し、有効期限が1ヶ月以内に近づいた場合にモバイル基盤グループへの依頼用リポジトリ宛に更新依頼 Issue を自動で立てることができます。

各アプリで使われているライブラリを一覧する

アプリごとに、使用しているライブラリとそのバージョンを一覧できます。 iOS はライブラリの管理方法が SwiftPM、CocoaPods、 Carthage と複数存在するので、それぞれに対し異なる収集方法で収集しています。 また、この収集した情報を活かし、「このライブラリを利用しているアプリ」も参照できます。 この機能は gem_collector にインスパイアされています。

ライブラリを利用しているアプリに向けての一括アナウンスをする

これもまた gem_collector にインスパイアを受けた機能になりますが、特定のライブラリを使用しているアプリの開発リポジトリに対して、一斉に Issue を起票することができます。

これは、ライブラリ側に問題が見つかり早急にアップデートを依頼したい場合や、社内ライブラリに破壊的な変更が加わった場合などに利用される想定で作られています。

ちなみに Hatamoto という名前は、モバイルアプリにまつわるデータを「監視する」という意味合いから監視役→衛兵→戦国時代における武将の近衛兵=旗本という流れで名付けられています。

各機能の実現方法

各機能は以下のように実装しています。

証明書の有効期限管理

各証明書の有効期限管理は、それぞれ以下の図のような流れで実現しています。

APNs 証明書の更新の際は、有効な証明書の有無を確認し、必要に応じて新規作成するという作業をまとめて行う fastlane の pem というアクションを利用しています*2

この機能を利用するためには、Apple Developer の AppManager 以上のユーザーでログインする必要があり、そのログインの際に他要素認証が求められることから、APNs 証明書の更新はモバイル基盤グループのメンバーによって手動で実行されています。 この更新の際、作成された証明書を Amazon S3 (以下 S3 )のバケットにアップロードするようにしているので、その中身を見て有効期限をチェックしています。

Provisioning Profile・開発者証明書の有効期限の取得には App Store Connect API を使っています。

有効期限が近づいているかどうかは、毎日定期実行されるジョブ*3で今日の日付と有効期限を比較して確認しています。

使用ライブラリの収集

各アプリが利用しているライブラリの取得は、パッケージマネージャーごとに異なる方法で実現しています。

Carthage、SwiftPM

GitHub API を使って各リポジトリ内の Cartfile.resolved、Package.resolved の中身を取得しパースしています。同じく GitHub API 経由で、利用しているライブラリの最新バージョンを取得しています。

CocoaPods

CocoaPods で解決されたライブラリのリストとそれぞれのバージョンが記述される Podfile.lock には、そのライブラリのリポジトリ URL が記載されません。そのため、SwiftPM や Carthage と同様に Podfile.lock の中身を基に GitHub API を利用してライブラリの最新バージョンを取得するというのは難しいです。 したがって、利用している CocoaPods ライブラリの取得及び最新の podspec の情報の取得は、各アプリのメインブランチの CI で実行するようにしました。ここで収集した情報を S3 にアップロードし、アップロードされた情報を同様に Kuroko2 のジョブを定期実行して取得し DB に登録する、という流れにしています。

Hatamoto を実際に運用してみて

Hatamoto を実際に運用してみて、最も良かったのは証明書の有効期限を気にする必要がなくなったことです。証明書は各アプリの管理者がダウンロードして差し替えることになるので、管理者であるモバイル基盤グループのオペレーションが減りました。

また、各アプリが使っているライブラリを一覧できるというのは、思いの外管理者以外の開発者の目線から役に立つ場面がありました。 たとえば、新しくライブラリを導入しようとしたときに、社内にそのライブラリを使っているアプリが他に無いかを調べ、導入方法を確認する、といった活用例がありました。

逆に、ライブラリを利用しているアプリに向けての一括アナウンスは未だ活用されていません。対応が必要な問題がみつかったときに、各アプリで開発者側が自発的に Issue を立てて対応していることが多く、開発者達の対応力の高さ故にあまり需要がない機能となってしまいました。

おわりに

この記事では、アプリの各種証明書や各プロジェクトが依存している開発ツール・ライブラリなど、 iOS アプリに関する情報を一元管理するための Web アプリケーション「Hatamoto」を紹介しました。 このツールのおかげで、 iOS アプリを開発していく上で面倒となる管理部分が効率化できています。App Store Connect API の機能が増えれば、証明書の生成フローなど更に自動化できる部分が増え、より便利に利用することもできそうです。

この Hatamoto は主に App Store Connect API と GitHub API 、fastlane を使って比較的単純な処理で実現されています。複数アプリにおける証明書等の情報管理に悩む方は、Hatamoto のようなWebアプリによる管理を試してみてはいかがでしょうか。

*1:社内ではアプリごとにライブラリの更新を監視するスクリプトを自前で書いて週次で実行していましたが、利用するバージョンを固定している場合や、更新の緊急性が高い場合にこれが機能しないという事情がありました。現在は、Renovate のようなツールを利用するのも解決手段の一つだと思います。

*2: App Store Connect API には証明書を作成する機能がないため、作成処理を完全に自動化することは現状できません

*3:クックパッドで利用されている Ruby 製の Web ベースのジョブスケジューラー Kuroko2 を利用しています

開発を快適にするiOSアプリ内ログ確認ツール

開発を快適にするiOSアプリ内ログ確認ツール(履歴・定義辞書・チェックリスト)

こんにちは!レシピ事業部の藤坂(@yujif_) です。

クックパッドiOSアプリの開発者用機能として「ログ確認ツール」を作ってみました。社内で1年以上運用して好評なので、その経緯や学びをまとめてみます。

iOSDC Japan 2022 では「モバイルアプリの行動ログの『仕込み』を快適にする」と題して関連した内容を発表しています。こちらの資料も合わせてご覧ください。

speakerdeck.com

アプリ内ログ確認ツールとは

クックパッドiOSアプリに内蔵された、ログ関連の開発者用ツール*1です。 「ログ履歴」「ログ定義辞書」「ログ送信チェックリスト」の3つの機能があります。

1. ログ履歴

クックパッドiOSアプリ内のログ確認ツール

ユーザーの行動ログ(例:ボタンのタップ、特定の要素の表示など)やAPIサーバーとの通信ログを、クックパッドiOSアプリの中ですぐに確認できます。

開発版のクックパッドiOSアプリ内で、ログ確認ツールを素早く表示できる様子

ログ確認ツールを素早く表示できる様子*2

このツールはデバイスのシェイク(Simulatorでは ⌃ ⌘ Z control + command + Z)でも表示でき、どの画面からでも気軽に使えます。

ログの内容確認が楽になる

必要な情報だけに絞り、種類別に色分けすることで、パッと見て把握しやすくしています。

これまでもロガーからの出力は Xcode 内のコンソール*3や Console.app などで確認できました。

Xcode 14のスクリーンショット。コンソールに行動ログの中身が表示されている。
[ログ確認ツールがなかった頃] Xcodeのコンソールに流れるログは見づらい

しかし、関係のない情報もたくさん流れてくるし、見た目もJSONそのままだったり、差分も分かりづらかったりと、人間にとっては疲れるものでした。

行動ログの構成要素

  • そのログ特有の付加情報
    • 例:対象リソースID、検索キーワード など
  • 全ログ共通で付加されている情報
    • 例:ユーザーID、端末OSバージョン、アプリバージョン など

1行のログには様々な情報が含まれますが、全てのプロパティを常に見たいわけではありません。固有の情報だけに絞って、内容の確認に集中しやすくしました。

2. ログ定義辞書

全ログ定義を一覧表示し、横断検索もできる。iPadにも対応。

送信されたログだけでなく、いま定義されている全てのログについても辞書のように調べられます。

知らないログの意味が分かる

ログ定義ドキュメントの活用

クックパッドでは、Markdown形式のログ定義をもとに型安全なログ実装用コードを生成する仕組みを3年以上運用しています。

techlife.cookpad.com

この仕組みのおかげで、ログ定義一つ一つに必ず説明文が用意されています。何のために導入されたログなのか、パラメータにはどのような値が入るのか、注意点は何か、当時のログ設計者が記したドキュメントから把握できます。

担当領域外にも目を向けやすく

そんな便利なログ定義Markdownですが、沢山の .md ファイルがただ置いてあるだけでは活用されません。 自分の担当領域のログは詳しくても、他の誰かが入れたログは何かきっかけがないとなかなか見ないものです。

アプリ内で見やすくなることで、触っているうちに自然と「こんなのあったんだ!こういうときに使えそう💡」といった境界を越えた発見が生まれるのを狙った部分もあります。

ログを業務に活かしやすくなる

ログ定義画面の「Bdash ServerでSQL例を探す」ボタンをタップすることで、社内のデータ分析SQLと実行結果にすぐたどり着けている図
ログ定義から、社内の分析SQLの例をすぐに探せる

ログを見にきた人は何かを調査・分析したい人のはずなので、それを手助けするリンクを用意しています。

集計・分析へのショートカット

例えばバナーの表示やタップのログであれば、集計SQLを書いて施策の効果検証をしそうです。

クックパッドでは、データ分析SQLを共有できる社内Webサービス「Bdash Server」がよく使われています。そこで、各ログ定義に関するSQL例をBdash Serverですぐに検索できるボタンをつけました(上図)。

techlife.cookpad.com

「この結果が気になるけど、SQLを書くのがちょっと……」という人も、もしすでに誰かが作った結果で事足りるなら即解決できますし、少し違うとしても参考にできるSQLがあるだけで書きやすくなります。

溜まっている知見をなめらかに使えるようにして、全社での生産性向上を狙っています。

3. ログ送信チェックリスト

ログ送信確認チェックリストに4つのログ定義が並んでいる図。4つのうち、3つは「送信済み」だが、1つだけ「未送信」と強調表示されている。
チェックリストに追加すれば、未送信のログがあぶり出される

各ログ定義をチェックリストに登録しておけば、ログ送信時に自動でチェックされ、そのログが送信済み*4かどうかを一瞬で確認できます。

ログが送られていない!?

新機能をリリースしていざ分析しようとしたら、必要なログの一部が送れていないことに気づき、「オァー!実装が漏れてました 💦」と焦る、そんな失敗が実際にありました。

例:「定期便」機能を新たにリリースする場合

  • 見たい指標
    • 定期便初回登録時のファネル
    • キャンペーンごとの効果
    • 定期便解除数
    • 定期便ユーザーのLTV など

施策に関する意思決定者と「最終的に何を知りたいのか」の認識を揃えることで、必要なログが洗い出せます。指標によっては、サーバー側のデータで事足りるものもあれば、モバイルアプリ側での行動ログが必要不可欠な場合もあります。

  • 必要なログ
    • 定期便の商品詳細画面の表示
    • 定期便登録ボタンのタップ(≒定期便登録確認画面の表示)
    • キャンペーンバナーの表示
    • キャンペーンバナーのタップ
    • 定期便解除ボタンのタップ
    • …… などなど

ややこしいのは、ユーザー状態次第では送らないログもあることです。例えば、定期便の初回ユーザー限定のバナーの表示ログは、一度でも定期便登録をしたユーザーからは送られないはずです。他にも、無料会員と有料会員の差、キャンペーンの流入経路ごとの差など、組み合わせ次第でどんどん複雑化していきます。

仕様が複雑になるとミスもしやすいですし、品質保証のテストも手間がかかります。必要なログを漏れなくすべて送るのは結構大変です。チェックリスト機能はこの対策のために作ってみました。

QA作業でログの実装漏れもわかる

ログ送信チェックリストの使い方

まず分析に必要なログ定義を一通りチェックリストに入れます。次に、想定されるユーザーと同様の流れでアプリを操作します。

操作を終えたとき「すべて送信済み」となっているなら問題ありません。アプリをリリースしてOKです。もし「未送信あり」なら、ログ送信の実装漏れがどこかにあるということです。

このチェックリスト機能を使えば、品質保証(QA)の手動テストの時間で、ついでにログの実装漏れも検出できます。

実装担当者以外でも分担できる

アプリ単体で完結するので、Xcodeなどの開発環境も必要なく誰でも実施できます。複数パターンの検証も、エンジニアに限らずチームで分担して一気に進められるのはうれしい点です。

実装方法

アプリ内ログ確認ツールを実現するには、どうすればよいでしょうか?

まず、送信済みログを読み出せること。次に、それらをログ定義ドキュメントとうまく紐付けて扱えること。この2つが必要です。

クックパッドiOSアプリのログ関連の実装概要図

送信済みログを端末内で保持するなら、ファイルへの出力、インメモリでの保持などいくつか方法が考えられます。 今回は、iOSの統合ロギングシステム(以下、OSログ)を活用することにしました。

Logging | Apple Developer Documentation

1. 送信済みログを読み出せるようにする

クックパッドiOSアプリでは、ログ収集ライブラリとして Puree-Swift を使っています。

techlife.cookpad.com

os.Logger で書き込む

以下のようなコードで、簡単にOSログに出力できます。

import os

// ログの出自がわかるように subsystem と category を指定
let logger = Logger(
    subsystem: Bundle.main.bundleIdentifier!,
    category: "ActivityLog"// 行動ログの場合の例
)

let logDataString = """
{
    "user_id": 1234567890,
    "event_category": "recipe_detail",
    "event_name": "tap_save_button",
    "recipe_id": 123456
}
"""

// ログを書き込む
logger.notice("\(logDataString)")

例えば、以下のように OSログ出力用の Puree-Swift のOutputを定義してConfiguration に加えると、ログサーバーへ送信されるログと同じ内容が、端末内のOSログにも出力されます。

より詳細なPuree-Swift の Output 実装例はこちら
import Foundation
import os
import Puree

final class OSLogOutput: InstantiatableOutput {
    let tagPattern: TagPattern
    private let logger: os.Logger // iOS 14+ で利用可能

    required init(logStore: LogStore, tagPattern: TagPattern, options: OutputOptions?) {
        self.tagPattern = tagPattern

        logger = Logger(
            subsystem: Bundle.main.bundleIdentifier!, 
            category: "ActivityLog" // 行動ログの場合の例
        )
    }

    func emit(log: LogEntry) {
        guard let userData = log.userData else {
            assertionFailure("logEntry must have userData")
            return
        }

        guard let payload = try? JSONSerialization.jsonObject(with: userData, options: []) as? [String: Any] else {
            assertionFailure("Cannot decode userData as JSONObject.")
            return
        }

        if let logDataString = prettyJSONString(payload) {
            logger.notice("\(logDataString, privacy: .public)")
            // デフォルトでは情報がマスクされるが、開発版ビルドのみなので、`.public` にしている
        }
    }

    private func prettyJSONString(_ object: Any) -> String? {
        guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
}

この os フレームワークの Logger ですが、以下のような特徴があります。

  • 非常に効率的で、アプリの動作遅延なく使える*5
  • Console.app や Xcode のコンソールでログを確認できる
  • センシティブな情報はマスクできる(指定して公開もできる)

developer.apple.com

OSLogStore で読み出す

アプリ内ログ確認ツールで利用する際は、OSLogStore を使ってOSログから読み出しています。これは iOS 15以降で利用できます。

OSLogStore | Apple Developer Documentation

import OSLog

protocol OSLogEntriesDataStoreProtocol: AnyObject {
    func fetchEntries() async throws -> [OSLogEntry]
}

final class OSLogEntriesDataStore: OSLogEntriesDataStoreProtocol {
    func fetchEntries() async throws -> [OSLogEntry] {
        let store = try OSLogStore(scope: .currentProcessIdentifier)
        let predicate = NSPredicate(format: "subsystem == %@", Bundle.main.bundleIdentifier!)

        return try store.getEntries(matching: predicate)
            .reversed()
        // Workaround: `store.getEntries(with: .reverse, matching: predicate)` で降順(新しいログが先)に返されるはずだが、iOS 16時点では機能しないため、ここで逆順にしている。
        // ※追記:iOS 17で直っていました!
    }
}

2. ログ確認ツールで扱いやすいデータに変換する

OSLogEntry のメッセージをデコードする

読み出した OSLogEntrycomposedMessage には行動ログの中身が入っていますが、この時点ではただのJSON文字列です。以下のようなコードで中身をデコードしてアプリ内ログ確認ツールで扱いやすくします。

OSLogEntry のままでは category *6 (先ほどの例では "ActivityLog" という値)を参照できないので、OSLogEntryWithPayload にダウンキャストします。

import OSLog

struct LogEntryResolver {
    /// OSLogEntryから必要な情報を取りだして、アプリ内ログ確認ツールで扱いやすいモデルに変換します
    static func resolve(entry: OSLogEntry) -> CookpadLogEntry? {
        guard let entryWithPayload = entry as? OSLogEntryWithPayload else { return nil }
        guard let payload = decode(message: entry.composedMessage, category: entryWithPayload.category) else { return nil }
        return CookpadLogEntry(id: UUID().uuidString, date: entry.date, payload: payload)
    }
}

行動ログの定義ドキュメントとの紐付け

クックパッドでは、Markdown形式のログ定義から型安全なログ実装用コードを生成するために daifuku *7 というライブラリを使っています。

今回はその仕組みを応用し、アプリ内ログ確認ツールからログ定義ごとの解説情報を参照できるコードを自動生成するようにしました。*8

ログ定義ごとの解説情報の用意

例えば、下記のように解説情報のためのstructを定義します。

struct ActivityLogDefinition: Hashable {
    /// ログイベントカテゴリ(例: `sagasu` )
    var category: Category
    /// ログイベント(例:`show_content`)
    var event: Event

    struct Event: Hashable {
        /// ログイベント名(例:`show_content`)
        var name: String
        /// ログイベントの解説文(例:`さがすタブのコンテンツが画面に表示された時に送信されます。`)
        var description: String
        /// ログイベントに付加されるパラメーター
        var parameterNotes: [ParameterNote]

        /// 各ログに付加されるパラメーターについての解説
        struct ParameterNote: Hashable {
            /// パラメーターのキー名(例:`hashtag_ids`)
            var name: String
            /// パラメーターの解説文(例:`表示されたハッシュタグID`)
            var description: String
            /// パラメーターのSwiftでの型名(例: `String?` )
            var swiftType: String
        }
    }
}

適当にRuby スクリプトを書いて、daifukuを使ってログ定義Markdownの情報を扱い、テンプレートをもとにログ解説情報を Swift の enum として自動生成します。

Markdown からログ定義の enum を生成するRubyスクリプトの例 https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb

自動生成用のテンプレートファイル (.erb) の例
// This file is automatically generated by generate-log-classes.

extension ActivityLogDefinition {
    enum Category: String, Hashable, CaseIterable {
        <%- categories.each do |category| -%>
        case <%= category.variable_name %> = "<%= category.name %>"
        <%- end -%>
    }
}

extension ActivityLogDefinition.Category {
    var description: String {
        switch self {
    <%- categories.each do |category| -%>
        case .<%= category.variable_name %>:
            return  """
            <%- category.descriptions.flat_map(&:lines).each do |description_line| -%>
            <%= description_line.strip %>
            <%- end -%>
            """
    <%- end -%>
        }
    }

    var events: [ActivityLogDefinition.Event] {
        switch self {
    <%- categories.each do |category| -%>
        case .<%= category.variable_name %>:
            <%- if category.available_events.empty? -%>
            return []
            <%- else -%>
            return [
                <%- category.available_events.each do |event| -%>
                .init(
                    name: "<%= event.name %>",
                    description: """
                    <%- event.descriptions.flat_map(&:lines).each do |description_line| -%>
                    <%= description_line.strip %>
                    <%- end -%>
                    """,
                    parameterNotes: [
                    <%- event.columns.each do |column| -%>
                        .init(
                            name: "<%= column.original_name %>",
                            description: """
                            <%- column.descriptions.flat_map(&:lines).each do |description_line| -%>
                            <%= description_line.strip %>
                            <%- end -%>
                            """,
                            swiftType: "<%= column.swift_type %>"
                        ),
                    <%- end -%>
                    ]
                ),
                <%- end -%>
            ]
            <%- end -%>
    <%- end -%>
        }
    }
}

こうして用意した解説情報を、送信済みログと紐付けます。

送信済みログとの紐付け

送信済みログの中身に含まれる eventCategoryeventName の値から、どのログ定義かは一意に定まります。下記のように、送信済みログのペイロードからログ定義解説情報を参照できるようにしました。

extension ActivityLogPayload.DefinitionKey {

    /// 行動ログの定義ごとの解説情報
    var definition: ActivityLogDefinition {
        guard
            let category = ActivityLogDefinition.Category(rawValue: eventCategory),
            let event = category.events.first(where: { $0.name == eventName })
        else {
            fatalError("ログ定義が見つかりませんでした")
        }
        return ActivityLogDefinition(category: category, event: event)
    }
}

3. 便利な機能を色々実装する

あとは「扱いやすくした送信済みログ」と「解説情報」を材料として、自由に料理して好みの画面をつくるだけです。ここでは雑多にいくつかのトピックをご紹介します。

チェックリスト機能

チェックリスト機能は、次のような単純な実装です。

  • チェックリストに登録したログ定義のキーを UserDefaults で保持しておく。
  • 送信済みログの中に、そのキーと一致するログが1つでもあれば、チェックリスト上でそのログ定義を「送信済み」にする。

ネットワーク通信のログにも対応

行動ログだけでなく、ネットワーク通信のログも見られるようにしています。実際にアプリを操作しながら、どのAPIエンドポイントがどのタイミングで使われているのか、すぐに確認できるのは便利です。

Request や Response の詳細も良い感じに表示*9

CharlesProxyman などのサードパーティーアプリのほうが高機能ですし網羅性も高い*10ですが、常に起動しているとも限らないですし、いざ使いたいときにちょっと手間がかかります。

障害発生時の調査にも役立った

例えば、特定の画面がエラーになるといった障害が発生したとき、アプリ単体でも素早く調査できたのは便利でした。発生条件の特定や原因の切り分けがスムーズにできると、より焦らずに対応できます。

なお、ネットワーク通信のログについては OSログには送らず、メモリ上に保持しています。 *11

実装に関して調査しやすくする

ネットワーク通信のログから、API仕様の調査やソースコード検索がすぐできる

行動ログの集計・分析ショートカットと同様に、ネットワーク通信のログを見にきた人はAPIや実装に関して色々調査をしたいはずだということで、以下の社内Webサービスへのリンクを用意しています。

  • APIサーバーに対して実際のリクエストを手軽に試せる「API3 Console」
  • APIサーバーのスキーマ定義からドキュメントを提供する「Garage Playground*12
  • ソースコードやタスク、プロジェクトの管理をしている「GitHub Enterprise」

例えば 「実装箇所を GitHub Enterprise(GHE)で表示する」ボタンは、レポジトリ内のSwiftコードの検索結果のURLを開くだけですが、秒で利用箇所が見つかるのは思っている以上に便利です。

小ネタですが、次のような工夫も入れています。

// recipeID, userID などを含むURLは、GHE検索時にヒットせず不便なので * に変換している
// (例: `/v1/recipes/:id`, `/v1/users/:id/visited_recipes` など)
let query = request.url.path.replacingOccurrences(of: "/([0-9]+)(/|$)", with: "/*$2", options: .regularExpression)

SimulatorではURLコピーに

実機ではブラウザで開き、SimulatorではURLをコピーする

さらに小ネタですが、iOS Simulatorで開発中にこのボタンを使うと、Simulator内のSafariが開いてしまい不便*13だったので、こんな対策をしました。 Simulator実行時は URLをクリップボードにコピーするので macOS側 ですぐ開けます。iPhone/iPadの実機では実機のブラウザが開きます。

// リンクボタンの実装例

   var body: some View {
        #if targetEnvironment(simulator)
        buttonForSimulator(targetURL)
        #else
        buttonForRealDevice(targetURL)
        #endif
    }

    private func buttonForSimulator(_ targetURL: URL) -> some View {
        CopyTextButton(
            stringToCopy: targetURL.absoluteString,
            labelTitleForCopied: "URLをコピーしました(Simulator内のSafariで開くと不便なので)"
        ) {
            label
        }
    }

    private func buttonForRealDevice(_ targetURL: URL) -> some View {
        Link(destination: targetURL) {
            label
        }
        .contextMenu { // 実機でも長押しメニューから一応コピーできるようにしている
            CopyTextButton(stringToCopy: targetURL.absoluteString) {
                Label("URLをコピー", systemImage: "doc.on.doc")
            }
        }
    }
import SwiftUI

struct CopyTextButton<Content: View>: View {
    var stringToCopy: String
    var labelTitleForCopied: String
    @ViewBuilder var content: Content

    init(stringToCopy: String, labelTitleForCopied: String = "コピーしました!", @ViewBuilder content: () -> Content) {
        self.stringToCopy = stringToCopy
        self.labelTitleForCopied = labelTitleForCopied
        self.content = content()
    }

    @State private var isCopied = false

    var body: some View {
        Button {
            UIPasteboard.general.string = stringToCopy
            print("[LogChecker] Copied to the pasteboard: \(stringToCopy)")
            Task {
                defer { isCopied = false }
                isCopied = true
                try? await Task.sleep(for: .seconds(3))
            }
        } label: {
            if isCopied {
                Label(labelTitleForCopied, systemImage: "doc.on.doc")
                    .font(.callout)
                    .foregroundColor(.secondary)
                    .imageScale(.small)
            } else {
                content
            }
        }
    }
}

振り返って

よかったこと

このログ確認ツールは、色んな面で開発を楽しくできました。

ログの実装・確認がつらくなくなる

「大事だけど正直面倒な作業」とも感じていたログの実装や確認を幾分か快適にできたと思います。個人的にはアプリ内ログ確認ツールを使いはじめてからは「ちょっと楽しいまである」という気持ちに変化していました。同僚からもSlackなどで「すごい見やすくなってる!」「はちゃめちゃに助かっている」「課金したい」といったポジティブな反応をもらえています。

自由に実験できる環境で遊べる

Viewについては、すべてSwiftUIで実装しました。

普段、一般ユーザー向けに開発している画面は SwiftUI (場合によっては UIKit)を採用していますが、全体的には VIPER アーキテクチャで、画面遷移も UINavigationController をベースに使っています。

techlife.cookpad.com

今回は開発者用ツールということもあって、サポートOSバージョンや不具合などはそこまで気にしなくても済む状況でした。むしろ、こういう機会に積極的に新しい技術を試して、知見を貯めるほうが望ましいでしょう。

チームで合意をとり、ログ確認ツールに関しては @available(iOS 16.0, *) (今なら iOS 17)をつけて、最新のSwiftやSwiftUIの機能を使い放題にしました。制約なく技術を楽しめるエンジニアにとってのオアシスのような場所です。

例えば NavigationStackNavigationSplitView など、普段使っていないSwiftUIの画面遷移関連も試しています。 正規表現を使う箇所では、RegexBuilder も試しました。

ViewThatFitsを使えば、簡単に解決できてすごく便利だ」「この書き方、iOS 17から deprecated になるのか!」「これ便利だけど、この挙動は気を付けないと不具合を生み出しそうだ……」

このように自由に実践して得られた知見や肌感覚は、ただ楽しいだけではなく、近い将来のユーザー向け機能の開発をスムーズにして、とても役立ちます。

欲しいものを作れると楽しい

あったらいいなを次々と実現するのが純粋に楽しかったです。 *14

改善の無限ループが爆速で進むのは楽しい

開発者向けツールはユーザーが自分でもあるので、ニーズの理解も、真に解決できているのかの実感もすぐできます。 作ってみて、試しに使って、新たな発見があってまた作る、この改善が爆速で進められます。

業務にしっかり役立つ「仕事」ではあるものの、楽しくてついやってしまう「趣味」でもあり、「趣味の仕事」という言葉が社内で流行していました。このアプリ内ログ確認ツールも趣味の仕事の一例です。

改善したいこと

ログの読み込みを速くしたい

OSログは書き込みは良いですが、読み込みは遅いようです。 os.signpost と Instruments を使って計測してみると、.getEntries*15 の1行だけで圧倒的に時間がかかっています。

動作環境によって大きく差があり、気にならない程度のときもあれば10秒近くかかってさすがに使いづらいと感じるときもあります。Simulatorと実機の差、OSログに溜まった量の差などいくつか要因がありそうな気もしつつ、詳しくはまだ調べられていません。

画面表示毎に更新すると読み込みで待ちすぎるので、今はキャッシュ層を挟んで更新頻度を下げています。

まとめ

今回は、アプリ内ログ確認ツールの機能や実装方法、分かったことについてご紹介しました。

まだ改善の余地はありますが、ちょっとした工夫の積み重ねによって開発を快適にする目的は一定達成できたと感じています。

日々のサービス開発をより良くするために、この記事が何か少しでも参考になったら幸いです。

*1:このツールは開発版ビルドのみに含まれており、App Store版では利用できません。

*2:SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。

*3:なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。https://developer.apple.com/videos/play/wwdc2023/10226/

*4:ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。

*5:https://developer.apple.com/videos/play/wwdc2020/10168/ より

*6:https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category

*7:ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。https://techlife.cookpad.com/entry/2020/12/29/004145

*8:ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。

*9:このJSONの表示部分は、同僚のNiaさんの SwiftUI で JSON を表示する View を使わせてもらいました。https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8

*10:アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。

*11:元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚のVincent さん が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。

*12:クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。https://techlife.cookpad.com/search?q=Garage

*13:Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。

*14:Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: https://youtu.be/2HitJxXXzwY?t=1325

*15:https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries