NotionとStencilでiOSの行動ログ生成自動化と運用
これはmikan Advent Calendar 2022 21日目の記事です。
20日目はのぶこさんによる「仕事と家庭をうまく回すための工夫〜mikanの福利厚生をフル活用しながら〜」でした。Sick Leaveをこの規模の会社で取り入れられているのはほんとすごい
モバイルアプリ開発の悩みのタネの一つに行動ログの実装があると思います。なんの画面を開いたか、どんなボタンを押したかとかを取得するアレです。モバイルエンジニアだけでなく、分析チームやPMなどの設計やビジネス的な変更が多分に入り込み、以下のような課題がよく出てきていました。
- 想定していたログについてビジネスサイドと認識合わせるのが大変(ログを追加・変更したけどアプリに反映されていない など)
- プロパティの変更やログの削除が気軽にできない
そこでmikanでは普段ドキュメントなど全社的に利用しているNotionを活用して行動ログの定義から自動生成、そして変更検知の仕組みを実現しました。
運用して半年ほどですが、以下のようなメリットを実感しています。
- 新しい機能実装時のログに関するコミュニケーションコストが格段に減った
- 仕様によってどのようなログが定義、利用されているのか後からわかりやすい
- ログデータベースの変更を検知する仕組みを構築しているので変更し忘れや削除し忘れが発生しない
@ry0_110の「mikanにデータ分析担当として入社して半年が経ったので振り返り🍊」でも紹介してもらったように、エンジニアとログの設計・管理者とのコミュニケーションが大幅に改善することが一番大きいメリットかなと思います。
今回はNotionを使った行動ログ生成基盤をSwiftで実装してみましたのでご紹介します。
ログの定義
新しい画面や機能を実装する時にはほぼ必ず新しいログの実装やプロパティの追加が求められます。要件を定義する時、Notionのログデータベースに以下のような形でログの定義をしてもらいます。
location
やtarget
、action
でどの画面でどのようなアクションを実施しているのかを表現しています。data(parameters)
がログ送付時に添付するパラメータです。
data(parameters)
にはパラメータ名だけではなく、型も合わせて指定する形になっています。
// Non Optional
book_id: String!
// Optional
author_id: Int?
オプショナル含め型も合わせて定義してもらうことで、ログの解析時や実装時に「このパラメータってString?Int?」「nullがくることは想定していなかった」など実装後分析時に発生する実装ミスや認識齟齬をかなり減らすことができます。
Notionのすてきな機能の一つにLinked Databaseがあります。mikanではRFPや仕様書はNotionで書いているのですが、そこにログデータベースを添付してフィルタリングすることで、その仕様に関わるログをフィルタリングして共有することができます。
新しい機能を実装する時に仕様書を見ればどんなログを実装しなければならないのかがひとめでわかるので実装時のログ定義が非常に楽です。
Notion API
ログの定義が終わったら次はiOSアプリ側でログの生成です。
NotionはAPIを公開しており、今回はデータベースの情報を取得するQuery a database API
を使います。
以下のようにURLSessionを用いてNotionから値を取得します。
let url = URL(string: "https://api.notion.com/v1/databases/\(dataBaseId)/query")!
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.allHTTPHeaderFields = [
"Notion-Version": "2021-08-16",
"Authorization": "Bearer \(notionToken)"
]
request.httpMethod = "POST"
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
request.httpBody = try encoder.encode(RequestBody(
sorts: [
.init(property: "tags", direction: "ascending"),
.init(property: "created at", direction: "ascending")
],
filter: .init(or: [
.init(property: "deleted", checkbox: .init(equals: false))
]),
startCursor: cursor))
let (data, _) = try await URLSession.shared.data(for: request)
最近はasync/awaitでSwiftのコマンドラインツールも書けるようになったので便利になりましたね。
一点注意点としては、NotionのQuery APIは1回で最大100件までしか取得できないため、全件取得するためにはPaginationを参考にnext_cursorがなくなるまでリクエストする必要があります。
while cursor != nil {
let data = try await apiRequest(cursor: cursor)
let response = try decoder.decode(NotionResponse.self, from: data)
records += response.results
cursor = response.nextCursor
}
StencilでSwiftコードの生成
Notion APIから取得できたら次はコード生成です。コード生成にはみんな大好きStencilとStencilSwiftKitを使っています。Stencilは指定したテンプレートを元に、柔軟にソースコードを生成することが可能なツールです。
今回は以下のようなテンプレートを自作しました。
{% for log in logs %}
/// {{log.overview}} {{log.action}} ログ
///
/// [Notion:{{log.overview}}]({{log.url}})
public struct {{log.name|snakeToCamelCase}}: ActionLogType {
{% if log.parameters %}
public struct Parameters: Encodable {
..
}
{% else %}
public typealias Parameters = EmptyParameters
{% endif %}
public let location: String = "{{log.location}}"
public let target: String? = {% if log.target %} "{{log.target}}" {% else %} nil {% endif %}{{br}}
public let action: String = "{{log.action}}"
public let parameters: Parameters?
public init({% for param in log.parameters %}{{param.argument|snakeToCamelCase|lowerFirstLetter}}: {{param.type}}{% if not forloop.last %}, {{br}}{% endif %}{% endfor %}) {
..
}
}
{% endfor %}
これを元にStencilSwiftKitでコード生成します。
let context: [String: Any] = ["logs": logs]
let templateUrl = Bundle.module.url(forResource: "logs", withExtension: "stencil")!
let template = try String(contentsOf: templateUrl)
let swiftCode = try stencilSwiftEnvironment(trimBehaviour: .smart).renderTemplate(string: template, context: context)
すると以下のようなコードが生成されます。
/// サンプル画面の表示 impression ログ
///
/// [Notion:サンプル画面の表示](https://www.notion.so/{page_id})
public struct SampleImpression: ActionLogType {
public struct Parameters: Encodable {
public let bookId: String
public let chapterId: String?
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case chapterId = "chapter_id"
}
}
public let location: String = "sample"
public let target: String? = nil
public let action: String = "impression"
public let parameters: Parameters?
public init(bookId: String, chapterId: String?) {
self.parameters = .init(
bookId: bookId,
chapterId: chapterId
)
}
}
これでログ生成まで完了しました。あとは指定箇所で呼び出すだけです。
@Resolve(\.analytics) var analytics
analytics.record(log: ActionLogs.SampleImpression(bookId: bookId, chapterId: nil))
Notionで管理することの大きなメリットの一つとしては、Notion APIからレコード自体のURLが取得できることです。コメントにログ名とそのレコードURLをコード生成時に表示するようにすることで、Quick Helpから直接Notion Pageに飛ぶことが可能です。
Notion Pageにそれぞれログを作成した経緯なども書いておくことでログ自体の設計書にすることもできますし、backlinkを仕込んでおくことで実装時の仕様書やFRPにもすぐに飛ぶことができます。
修正自体もNotion Pageに飛んですぐ該当のログを修正できるので、Notionからのコード生成によりお互いの連携がかなりしやすくなりました。
Log Databaseの運用
ログコード生成はすべてSwiftで書かれているため、iOSエンジニアであれば誰でもかんたんに修正と呼び出しが可能です。またSwiftPMでStencilなどのライブラリ管理をしているので、実行自体も手軽です。
swift run --package-path BuildTools -c release log_gen ./Logs.generated.swift
Notion Pageには編集履歴も残っており復旧も簡単なのでログ定義の修正もわりと気軽にできます。ログ生成時にdeletedフラグを用意しておりソースコード上の論理削除も可能にしています。「とりあえず使わないから削除しておくけど、分析サイドとしては見られるようにしておきたい」という時に便利です。
基本的にはログ定義時に上記のコードを実行すればよいのですが、追加作業時にのみログ生成していると知らない間にログの定義が変わっていた場合検知することができません。
検知する仕組みを作るためにGitHub Actionsでログの変更があればPull Requestを発行するワークフローを構築しています。
create-logs-pr:
runs-on: self-hosted
steps:
- uses: actions/checkout@v3
- name: make generate_logs
run: make generate_logs
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
with:
branch: bot/logs
delete-branch: true
base: main
title: "📝 Logs Updated [bot]"
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: peter-evans/enable-pull-request-automerge@v2
cronで毎日このワークフローが走るようにしておりAutomergeもONにしているので、サッとみてApproveすればmainブランチ上のコードはほぼNotion上のログ定義と同期されています。マジ便利。
まとめ
この仕組みを構築してから半年ほど経ちましたが、驚くほどログの実装が楽になりました。特にアナリストとのコミュニケーションコストが大幅に下がったなと実感しています。
Notionをビジネスサイド・エンジニアサイドともゴリゴリ活用している所ほど特に効果的なので、この記事がログ実装の一助になれれば幸いです。
mikanでは一緒に最高のiOSアプリを作るエンジニアやデザイナーを募集しています!!
Discussion