AIエージェントにアプリの中身を見せる — OSLog + NSLog二重出力でデバッグ自動化
AIエージェントにiOSアプリの開発を任せていると、ビルドやテストまでは順調に進む。でも「実行したら画面が真っ白」「ボタン押しても何も起きない」みたいな問題が出たとき、エージェントにはアプリの中で何が起きているか見えない。printをばら撒いてもstdoutに全部流れるだけで、エージェントが必要な情報を探すのに人間の目が必要になる。それじゃ意味がない。
そこでOSLog1を導入した。ただし、OSLogだけでは実機のCLIからログが取れないという落とし穴がある。結局NSLog2との二重出力が必要だった。この記事では、シミュレータでも実機でもAIエージェントがターミナルからログを取得できる基盤の構築方法をまとめる。
なぜprintデバッグじゃダメなのか
printデバッグが悪いわけじゃない。人間がXcodeのコンソールを目で追う分にはそれでいい。でもAIエージェントに任せるとなると、致命的な問題がある。
| 観点 | OSLog | |
|---|---|---|
| フィルタリング | 不可。全部stdoutに流れる | subsystem3/category/levelで精密フィルタ |
| レベル分け | なし。エラーも情報も混在 | debug/info/error/faultで使い分け |
| Releaseビルド | 呼び出し自体は残る(文字列補間コストも発生) | debugはリリースで出力スキップ、文字列評価コストもゼロ |
| AIからのアクセス | stdout全文を解析するしかない | log stream --predicate4で必要な情報だけ取得 |
ここで注意したいのがprintのリリースビルドでの挙動。print()はコンパイラによって除去されるわけではなく、リリースビルドでも呼び出し自体は実行される。デバッガ未接続時はstdoutの出力先がないため画面には出ないが、文字列補間のコスト(String生成・メモリ確保)はそのまま残る。一方OSLogはos_log_type_enabledで出力レベルを事前チェックし、不要なレベルでは文字列評価自体をスキップするため、リリースでもパフォーマンスに影響しない。
要するに、printだとエージェントに「この山のようなログから認証エラーを探して」と言っているようなもの。OSLogなら「認証カテゴリのエラーだけ見せて」と指定できる。
💡 OSLogは人間にとっても便利。Xcode ConsoleやConsole.appでsubsystem/categoryフィルタが使えるようになるので、自分のデバッグ効率も上がる。
OSLogの基本 — 3つの概念だけ覚えればいい
OSLog(os.Logger5)はApple標準の統合ログフレームワーク。iOS 14以降で使えるLogger構造体で、構造化されたログを出力できる。覚えるのは3つだけ。
| 概念 | 説明 | 例 |
|---|---|---|
| subsystem | アプリの識別子。Bundle IDを使うのが慣例 | jp.po-miyasaka.PlayTime |
| category | 機能ごとのカテゴリ | Boss, Quest, Auth, App |
| level | ログの重要度 | debug, info, notice, error, fault |
printとの違いを一目で。
// print — 構造なし、フィルタ不可
print("fetchMyRooms: querying rooms")
// OSLog — subsystem+categoryでフィルタ可能、レベル付き
Log.notice("fetchMyRooms: status=querying", .boss)
実機ログ取得の落とし穴
ここが一番ハマったポイント。OSLogは統合ログシステム(Unified Logging System)に書き込む。Xcode ConsoleやConsole.app6からは問題なく閲覧できるが、CLIツールからの取得には制約がある。
| 方法 | シミュレータ | 実機 | 備考 |
|---|---|---|---|
| Xcode Console | ○ | ○ | デバッグ接続中のみ |
| Console.app | ○ | ○ | GUIツール。AIエージェントからは使えない |
log stream (simctl) | ○ | — | シミュレータ専用 |
log collect --device | — | ✕ | root権限が必要で実用不可 |
idevicesyslog7 | — | △ | ASL/syslogのみ取得可。OSLogは取得不可 |
⚠️ macOSのlogコマンドに--deviceフラグは存在しない。 log stream --deviceで実機ログが取れるという情報は誤り。log collect --deviceは存在するがroot権限が必要(Must be root to collect logs from attached device)で、サンドボックス環境では使えない。
結論: 実機のログをCLIから取得するには、OSLogだけでは不十分。NSLogとの二重出力が必要。
Console.appは実機のOSLogを問題なく表示できる(subsystem/categoryフィルタ対応)。人間のデバッグにはこちらが最適。ただしGUIツールのため、AIエージェントからは利用できない。
実装 — NSLog二重出力戦略
Log構造体の定義
PlayTimeではLog.swiftに統一ログAPIを定義している。重要なのは、CLIからも取得可能にするためOSLogとNSLogの二重出力を内部で自動処理していること。
// PlayTime/Utilities/Log.swift
import Foundation
import os
enum LogCategory: String {
case boss = "Boss"
case auth = "Auth"
case quest = "Quest"
case app = "App"
}
struct Log {
private static let subsystem = "jp.po-miyasaka.PlayTime"
static let appPrefix = "miyashi"
static func debug(_ message: String, _ category: LogCategory) { ... } // OSLogのみ
static func notice(_ message: String, _ category: LogCategory) { ... } // OSLog + NSLog
static func error(_ message: String, _ category: LogCategory) { ... } // OSLog + NSLog
static func warning(_ message: String, _ category: LogCategory) { ... } // OSLog + NSLog
}
📝 設計のポイント: Log.notice/Log.error/Log.warningがOSLogとNSLogの両方に出力する二重出力メソッド。OSLogは統合ログ(Xcode Console、Console.app向け)に、NSLogはASL/syslog(idevicesyslog向け)に書き込む。Log.debugはOSLogのみに出力し、冗長化を最小限に抑えている。カテゴリはenumで定義されており、コンパイル時チェックが効く。appPrefix定数を変えるだけで別アプリに転用可能。
なぜNSLogとの二重出力が必要なのか
OSLog(os.Logger)はAppleの統合ログシステムに書き込む。これはidevicesyslogが読み取るASL/syslogとは別のストレージ。一方NSLogは以下の両方に書き込む。
- stderr → Xcode Consoleで表示
- ASL/syslog → idevicesyslogで取得可能
つまりNSLogを併用することで、idevicesyslogからアプリのログが見えるようになる。
[miyashi_*] プレフィックス — grepで一発抽出
全てのログメッセージに[miyashi_<CATEGORY>]プレフィックスが自動付与される。miyashiはLog構造体のappPrefix定数で定義されている。
| プレフィックス | 機能 |
|---|---|
[miyashi_BOSS] | ボス機能(部屋作成、ダメージ送信等) |
[miyashi_AUTH] | 認証(Sign in with Apple、匿名認証等) |
[miyashi_QUEST] | クエスト機能 |
[miyashi_APP] | アプリ全体のライフサイクル |
これでgrep "miyashi_"するだけで、OSのフレームワークログ(Security、Network等)を全部排除して自アプリのログだけ取れる。さらにkey=value形式で構造化しているので、grep "error="でエラーだけ、grep "roomId="で特定ルームの操作だけ追跡できる。プレフィックスを変更したい場合はappPrefix定数を1箇所変えるだけでよい。
使い方 — レベルの使い分けが肝
二重出力(notice/error)と OSLog専用(debug)の使い分け
// BossActionCreator.swift — ボス機能のアクション
func sendDamageToRoom(roomId: String, questId: String, damageMinutes: Int) {
guard damageMinutes > 0 else { return }
guard Auth.auth().currentUser != nil else {
Log.notice("sendDamageToRoom: status=skipped reason=no_authenticated_user", .boss)
return
}
Log.notice("sendDamageToRoom: roomId=\(roomId) questId=\(questId) damageMinutes=\(damageMinutes)", .boss)
// ...
}
リポジトリ層でも同じパターン。
// BossRepository.swift — Firestoreとの通信層
func fetchMyRooms(completion: @escaping (Result<[BossRoom], Error>) -> Void) {
guard let uid = currentUid else {
Log.error("fetchMyRooms: error=not_authenticated", .boss)
completion(.failure(BossError.notAuthenticated))
return
}
Log.notice("fetchMyRooms: status=querying", .boss)
// ...
}
ルールはシンプル。
- Log.error: 本来起きてはいけない異常(認証切れ、通信失敗等)→ OSLog + NSLog二重出力
- Log.notice: 重要な状態遷移(関数呼び出し、パラメータ記録)→ OSLog + NSLog二重出力
- Log.warning: 注意が必要だがエラーではない状態 → OSLog + NSLog二重出力
- Log.debug: 開発時だけ見たい詳細情報(クレデンシャル型、トークン有無等)→ OSLogのみ
ガード条件でスキップされた場合もログを出すのがコツ。「なぜダメージが送信されないのか」をエージェントが追跡できる。
debugレベル — Xcode Console専用の詳細情報
Sign in with Apple8のデバッグでは、非同期フローの各ステップにログを仕込んでいる。
// AuthService.swift — Apple認証
func handleAppleSignIn(authorization: ASAuthorization, ...) {
Log.notice("SIWA handleAppleSignIn: status=called", .auth)
// debugレベル = OSLogのみ。Xcode Consoleで見る用
Log.debug("SIWA handleAppleSignIn: credentialType=\(String(describing: type(of: authorization.credential)))", .auth)
guard let appleIDCredential = authorization.credential
as? ASAuthorizationAppleIDCredential else {
Log.error("SIWA handleAppleSignIn: error=invalid_credential_type", .auth)
return
}
Log.debug("SIWA handleAppleSignIn: identityToken present=\(appleIDCredential.identityToken != nil)", .auth)
// ...
}
⚡ debugレベルはReleaseビルドでは出力されない し、NSLogにも流れない。クレデンシャルの型やトークンの存在確認など、開発中しか見ない情報はLog.debugで出すのが正解。
タイムアウト検知パターン
delegateコールバックが来ない問題の検知にも二重出力が役立った。
func performAppleSignIn(completion: @escaping (Result<Void, Error>) -> Void) {
Log.notice("SIWA performAppleSignIn: status=started", .auth)
controller.performRequests()
// 15秒でコールバックが来なければタイムアウト
DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in
if self?.appleSignInCompletion != nil {
Log.error("SIWA performAppleSignIn: status=timeout timeoutSec=15 error=delegate_callback_not_received", .auth)
}
}
}
実際にこのログのおかげで、Provisioning Profileの問題でdelegateが呼ばれないケースを発見できた。実機でもidevicesyslogからstatus=timeoutが確認できたので、Xcode接続なしで問題を特定できた。
AIエージェントからのログ取得
実機(iPhone) — idevicesyslog
idevicesyslogはlibimobiledevice9に含まれるCLIツール。brew install libimobiledeviceでインストールできる。
# リアルタイムで自アプリのログだけ取得
idevicesyslog -p PlayTime | grep "miyashi_"
# Boss機能のログだけ
idevicesyslog -p PlayTime | grep "miyashi_BOSS"
# 認証関連のみ
idevicesyslog -p PlayTime | grep "miyashi_AUTH"
# エラーだけ
idevicesyslog -p PlayTime | grep "error="
実際の出力(iPhone実機で検証済み)。
Mar 2 18:35:26.478903 PlayTime(PlayTime.debug.dylib)[8801] <Notice>: [miyashi_APP] didFinishLaunching: status=started
Mar 2 18:35:26.479102 PlayTime(Foundation)[8801] <Notice>: [miyashi_APP] didFinishLaunching: status=started
各ログが2行出力されるのは正常。1行目がOSLogのASL出力、2行目がNSLogのASL出力。
シミュレータ — log stream
# シミュレータでアプリを起動
xcrun simctl launch booted jp.po-miyasaka.PlayTime
# OSLogストリームをリアルタイム取得
xcrun simctl spawn booted log stream \
--predicate 'subsystem == "jp.po-miyasaka.PlayTime"' \
--level debug
# カテゴリでフィルタ: Boss関連のログだけ
xcrun simctl spawn booted log stream \
--predicate 'subsystem == "jp.po-miyasaka.PlayTime" AND category == "Boss"'
# エラーだけ
xcrun simctl spawn booted log stream \
--predicate 'subsystem == "jp.po-miyasaka.PlayTime"' \
--level error
💡 predicateはSQL風の構文。AND/ORで複合条件も書ける。「Boss機能のエラーだけ」なら category == "Boss" AND messageType == 16 で取れる。
AIエージェントの実際のワークフロー
エージェントがクラッシュを調査する際の典型的な流れ。
# 1. アプリをビルド&インストール(実機)
xcodebuild build -workspace playTime.xcworkspace -scheme PlayTime \
-destination "platform=iOS,id=<DEVICE_ID>" \
-derivedDataPath /tmp/DerivedData \
-allowProvisioningUpdates
# 2. ログ取得をバックグラウンドで開始
idevicesyslog -p PlayTime > /tmp/debug_log.txt &
LOG_PID=$!
# 3. アプリ起動 → 操作(またはUITest実行)
sleep 15
# 4. ログを回収して分析
kill $LOG_PID
# 自アプリのログだけ抽出
grep "miyashi_" /tmp/debug_log.txt
# エラーだけ確認
grep "error=\|status=failed" /tmp/debug_log.txt
# 特定機能のログだけ確認
grep "miyashi_BOSS" /tmp/debug_log.txt
grep "miyashi_AUTH" /tmp/debug_log.txt
ポイントはログ取得をバックグラウンドで先に開始してからアプリを操作すること。起動後にログを取り始めても、起動直後のエラーを取りこぼす。
ログ出力例
ボスタブを開いた際の正常系。
[miyashi_BOSS] ensureAuthenticated: status=already_signed_in
[miyashi_BOSS] fetchMyRooms: status=querying
[miyashi_BOSS] sendDamageToRoom: roomId=ABC123 questId=quest_001 damageMinutes=30
認証エラーが出た場合。
[miyashi_BOSS] fetchMyRooms: error=not_authenticated
[miyashi_AUTH] signInAnonymously: status=failed error=operation_could_not_be_completed
認証フローの時系列。
[miyashi_AUTH] SIWA performAppleSignIn: status=started
[miyashi_AUTH] SIWA performAppleSignIn: status=performing_requests
[miyashi_AUTH] SIWA delegate: status=didCompleteWithAuthorization
[miyashi_AUTH] SIWA handleAppleSignIn: status=called
[miyashi_AUTH] SIWA Firebase signIn: status=succeeded uid=abc123
エージェントはこのログを見て「匿名認証が完了する前にFirestore10クエリが走っている」と判断し、ensureAuthenticatedの呼び出しタイミングを修正できる。
実際に役立った場面
クラッシュ調査
ボスタブでのクラッシュ調査で、[miyashi_BOSS] fetchMyRooms: error=not_authenticatedがログに記録されていたことで、匿名認証完了前にFirestoreクエリが走っていることが判明した。printだったら他のログに埋もれて見つけるのに時間がかかっていたと思う。grep "miyashi_BOSS"で一発だった。
パフォーマンス分析
# タイムスタンプ付きでログを取得 → 関数間の時間差を計算
xcrun simctl spawn booted log stream \
--predicate 'subsystem == "jp.po-miyasaka.PlayTime" AND category == "Boss"' \
--style compact
タイムスタンプ比較で「Firestoreクエリに何秒かかっているか」を定量的に把握できる。
CI連携の可能性
テスト実行時にログを自動収集して、失敗テストの前後のログを添付する仕組みも作れる。
# テスト実行とログ収集を並行
xcrun simctl spawn booted log stream --predicate '...' > test_log.txt &
xcodebuild test -workspace ... -destination ...
kill %1
# test_log.txtをCI成果物として保存
📝 ログをCI成果物として残しておくと、後からテスト失敗の原因を調べるときにも使える。printだとCI上で全文を眺めることになるが、grep "miyashi_"で自アプリのログだけ絞り込める。
まとめ
やったことはシンプル。
Log.swiftにLog構造体 +LogCategoryenumを定義- 重要なログを
Log.notice/Log.error/Log.warningで出力(OSLog + NSLog二重出力) - 詳細情報は
Log.debugで出力(OSLogのみ、Xcode Console専用) - AIエージェントが
idevicesyslog -p PlayTime | grep "miyashi_"で実機ログ取得
| 観点 | OSLog単体 | OSLog + NSLog二重出力 | |
|---|---|---|---|
| フィルタリング | 不可 | subsystem/category/level | grep "miyashi_" + subsystem/category |
| Xcode Console | ○ | ○ | ○ |
| Console.app | ✕ | ○ | ○ |
| 実機CLI (idevicesyslog) | ✕ | ✕ | ○ |
| シミュレータCLI (log stream) | ✕ | ○ | ○ |
| Releaseパフォーマンス | 呼び出し残存+文字列補間コスト | debugは文字列評価スキップ | notice/errorのみ二重出力 |
| 構造化 | なし | subsystem + category + level | [miyashi_*] prefix + key=value |
コード変更はわずか — Log構造体を定義し、ログ呼び出しをLog.notice("msg", .boss)形式に統一するだけ。プレフィックス付与と二重出力は内部で自動処理される。OSLogだけで全部いけると思っていたけど、実機のCLIからは取れないという制約があった。NSLogとの二重出力という地味な解決策だけど、これでエージェントがターミナルコマンド一発で実機アプリのログを取得できるようになった。
自分の場合はNotionに「ボスタブでクラッシュするから調べて」と書くだけ。あとはエージェントがログを取得して原因を特定して修正してくれる。
Footnotes
-
OSLog — Apple標準の統合ログフレームワーク。iOS/macOS全体で統一されたログ基盤を提供する。Console.appやlog streamコマンドで閲覧可能。 ↩
-
NSLog — Foundation框架のログ関数。stderrとASL/syslogの両方に書き込むため、idevicesyslogから取得可能。OSLogより古いが実機CLI取得には必要。 ↩
-
subsystem — OSLogのログ分類の第1階層。アプリのBundle IDを設定するのが慣例。複数アプリのログが混在するシステムログから自アプリだけを抽出できる。 ↩
-
predicate —
log streamコマンドのフィルタ条件式。SQL風のWHERE句のようにsubsystem、category、messageTypeを組み合わせて絞り込める。 ↩ -
os.Logger — iOS 14+で使える構造体。subsystemとcategoryを指定してインスタンスを作り、
.info()/.error()等でログ出力する。 ↩ -
Console.app — macOS標準のログビューア。USB接続したiPhoneのOSLogをリアルタイム表示できる。subsystem/categoryフィルタ対応だがGUIのためAIエージェントからは操作不可。 ↩
-
idevicesyslog — libimobiledeviceに含まれるCLIツール。USB接続したiPhoneのsyslog/ASLを取得する。
brew install libimobiledeviceでインストール。 ↩ -
Sign in with Apple (SIWA) — AppleのOAuth認証サービス。App Store審査でサードパーティログインを提供するアプリには実装が必須。非同期のdelegateコールバック方式で動く。 ↩
-
libimobiledevice — iOSデバイスと通信するためのオープンソースライブラリ群。idevicesyslog、ideviceinstaller等のCLIツールを提供。 ↩
-
Firestore — Google Cloud Firestoreの略。Firebase上のNoSQLドキュメントデータベース。リアルタイム同期とオフラインサポートが特徴。 ↩