無人で回している Antigravity CLI のタスクが、ある朝から静かに空振りし始めていたのに、気づいたのは3日後でした。各ランは JSON Lines のログを1ファイルずつ吐いていて、logs/ の下には日付とタスク名で名付けられたファイルが 200 個以上。異常を疑って grep -l error logs/*.jsonl を叩いても、返ってくるのは「どのファイルに error という単語が含まれるか」だけです。どのステップが、どの頻度で、いつ落ちているのか——それを知るには結局ファイルを1つずつ開いて目で追うしかありませんでした。
ログは残していました。追えていなかっただけです。原因は保存の仕方ではなく、読み方の側にありました。ランが数十本のうちは grep で足ります。しかし個人開発で複数アプリの雑務をエージェントに預けるようになると、ランは週に数百本まで増えます。その規模になると、必要なのは「検索」ではなく「集計」です。私はこの切り替えに気づくのが遅く、しばらく朝の30分をログ読みに溶かしていました。
散らばった JSON Lines は、増えるほど読めなくなる
JSON Lines は1行1イベントで、追記に強く、壊れにくい良い形式です。ただ「1ラン1ファイル」で貯めていくと、横断的な問いに答えられません。「依存インストールのステップは先週から失敗率が上がっているか」「特定の時間帯だけ落ちていないか」——こうした問いは、ファイル単位では立てられないのです。
grep は行を返しますが、数えてはくれません。grep -c で数えられるのは1ファイル内の出現回数だけで、200 ファイルにまたがる「ステップ別の失敗数ランキング」は作れません。awk と sort を重ねれば近いことはできますが、問いを変えるたびにワンライナーを組み直すことになり、結局は使い捨ての集計に終わります。
必要なのは、問いを何度でも変えられる土台です。イベントを一度だけ構造化して1か所に集め、あとはクエリを差し替えるだけで別の角度から数えられる状態。その土台に、私は追加のサービスを立てずに済む SQLite を選びました。
取り込み先は、小さな SQLite ひとつでいい
観測基盤というと外部の分析サービスを思い浮かべますが、個人開発で回すランのログは、そのほとんどが1ファイルの SQLite に収まります。6 週間ぶん・214 ラン・約 1.9 万イベントを取り込んで、データベースは 18 MB ほど。サーバーもスキーマ移行の仕組みも要らず、sqlite3 ひとつで読み書きできます。
テーブルは2つに分けます。ランの単位(いつ・どのタスク・成功か失敗か)を持つ runs と、ラン内の各ステップの結果を持つ steps です。粒度を分けておくと、「ラン単位の失敗率」と「ステップ単位の失敗率」を別々に数えられます。
テーブル 主なカラム 役割
runs run_id / task / started_at / status / duration_ms 1ランを1行で表す。集計の起点
steps run_id / seq / step / status / duration_ms / error ラン内の各ステップ。失敗箇所の特定に使う
スキーマはこれだけです。run_id で2テーブルを結び、よく使う task と status に複合インデックスを張っておきます。
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY ,
task TEXT NOT NULL ,
started_at TEXT NOT NULL , -- ISO8601(UTC)
status TEXT NOT NULL , -- success / failure
duration_ms INTEGER
);
CREATE TABLE IF NOT EXISTS steps (
run_id TEXT NOT NULL ,
seq INTEGER NOT NULL ,
step TEXT NOT NULL ,
status TEXT NOT NULL ,
duration_ms INTEGER ,
error TEXT ,
PRIMARY KEY (run_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_runs_task_status ON runs (task, status );
CREATE INDEX IF NOT EXISTS idx_steps_step_status ON steps (step, status );
JSON Lines を runs / steps に流し込む
取り込みは追記です。まだ取り込んでいないログファイルだけを読み、run_id を主キーにして重複を弾きます。ここでは各ログが {"event": "...", "step": "...", "status": "...", "ts": "...", "ms": 123, "error": "..."} の形のイベント列で、ランの開始・終了イベントを含むことを前提にしています。実際のフィールド名は自分のラッパーに合わせて読み替えてください。要は「イベント列を runs 1行と steps N 行に畳む」だけです。
jq でイベントを畳み、sqlite3 の .import ではなくトランザクションで流し込みます。1ファイル=1トランザクションにしておくと、途中で失敗しても半端なランが残りません。
#!/usr/bin/env bash
# ingest.sh — logs/*.jsonl を runs.db に取り込む(取り込み済みは自動でスキップ)
set -euo pipefail
DB = "runs.db"
LOG_DIR = "logs"
sqlite3 " $DB " < schema.sql # 上のスキーマ。IF NOT EXISTS なので何度流しても安全
for f in " $LOG_DIR "/*.jsonl ; do
run_id = "$( jq -r 'select(.event=="run_start") | .run_id' " $f " | head -1 )"
[ -z " $run_id " ] && continue
# すでに取り込み済みなら飛ばす(追記の冪等性)
exists = "$( sqlite3 " $DB " "SELECT 1 FROM runs WHERE run_id=' $run_id ' LIMIT 1;")"
[ -n " $exists " ] && continue
# ラン1行を組み立てる
read -r task started status ms < <( jq -rs '
(map(select(.event=="run_start"))|first) as $s
| (map(select(.event=="run_end"))|first) as $e
| [$s.task, $s.ts, ($e.status // "failure"), ($e.ms // 0)] | @tsv' " $f ")
# ステップ行を TSV で書き出す
steps_tsv = "$( jq -rs '
[ .[] | select(.event=="step") ]
| to_entries[]
| [.value.step, .value.status, (.value.ms // 0), (.value.error // "")] | @tsv' " $f ")
{
echo "BEGIN ; "
echo " INSERT INTO runs VALUES ( '$run_id' , '$task' , '$started' , '$status' ,$ms ); "
seq=0
while IFS=$'\t' read -r step st sms serr; do
[ -z " $step " ] && continue
esc=" ${serr // \' / \'\' } " # シングルクオートのエスケープ
echo " INSERT INTO steps VALUES ( '$run_id' ,$seq, '$step' , '$st' ,$sms, '$esc' ); "
seq=$((seq+1))
done <<< " $steps_tsv "
echo " COMMIT ; "
} | sqlite3 " $DB "
done
echo " 取り込み完了: $( sqlite3 " $DB " 'SELECT COUNT(*) FROM runs;' ) ラン"
40 行ほどですが、ここまでで「散らばったログ」が「数えられるデータ」に変わります。ingest.sh は冪等なので、無人スケジュールの最後に毎回呼んでおけば、その日のランが自動で積み上がっていきます。
弱点を1本のクエリで数える
土台ができたら、あとは問いをクエリに翻訳するだけです。私が実際に朝いちばんに叩く6本を挙げます。どれも1行の問いに1行の答えを返してくれます。
まず、タスク別の失敗率。どのタスクが不安定かを一望します。
SELECT task,
COUNT ( * ) AS runs,
SUM ( status= 'failure' ) AS fails,
ROUND ( 100 . 0 * SUM ( status= 'failure' ) / COUNT ( * ), 1 ) AS fail_pct
FROM runs
GROUP BY task
ORDER BY fail_pct DESC ;
次に、失敗ランの中で「どのステップが原因だったか」をランキングします。これが grep では作れなかった集計です。
SELECT step,
COUNT ( * ) AS fail_count
FROM steps
WHERE status = 'failure'
GROUP BY step
ORDER BY fail_count DESC
LIMIT 10 ;
時間帯の偏りも一発でわかります。UTC の時刻を取り出してヒストグラムにすると、レート制限や外部 API のメンテ窓に噛んでいないかが見えます。
SELECT strftime( '%H' , started_at) AS hour_utc,
COUNT ( * ) AS runs,
SUM ( status= 'failure' ) AS fails
FROM runs
GROUP BY hour_utc
ORDER BY hour_utc;
残りの3本は、ステップ別の平均所要時間(AVG(duration_ms) を steps で GROUP BY step)、直近7日と前週の失敗率の差分(started_at を週境界で分けて比較)、同一ステップの error 文言の頻度(GROUP BY error で多い順)です。問いが変わってもテーブルは変えません。クエリを1本足すだけです。
私が実際に見つけた3つの弱点
6 週間・214 ランを取り込んで数えてみると、grep 時代には見えていなかったものが3つ出てきました。
見つかった弱点 数字 対処
依存インストールのステップに失敗が集中 全失敗 31 件中 18 件(58%) 該当ステップにリトライと固定バージョンを追加
失敗が特定の時間帯に偏在 失敗の 71% が UTC 17〜19 時に集中 そのタスクの発火を時間帯の外へ退避
同じ error 文言が別タスクにも波及 3 タスクで同一の 429 メッセージ 共有していたレート制限を1本の予算に集約
特に2つ目は、grep では絶対に気づけませんでした。ファイル単位では「たまに落ちる」ようにしか見えず、時刻でまとめて初めて偏りが立ち上がったのです。数える土台があると、「なんとなく不安定」という感覚が「17〜19 時に 71%」という一文に変わります。この一文があるだけで、翌朝の直しがまっすぐになりました。
判断の指針として私が置いているのは、失敗率そのものより「失敗が1か所に固まっているか、散らばっているか」です。固まっているなら直せます。散らばっているなら、まだ観測の粒度が粗いというサインだと考えています。
壊さないための運用ルール
小さな SQLite でも、無人で毎日書き込む以上、育て方に少しだけ作法が要ります。私が守っているのは次の3つです。
第一に、append-only を貫くこと。取り込みは INSERT だけにし、過去のランを UPDATE / DELETE しません。ログは事実の記録なので、後から書き換えると横断集計の信頼が崩れます。保持期間で古いランを落とすときも、runs.db 本体からは消さず、四半期ごとに別ファイルへアーカイブしてから DETACH する方針にしています。
第二に、複合インデックスを最初から張ること。(task, status) と (step, status) の2本があるだけで、数百ランを跨ぐ GROUP BY が体感で待たされなくなります。データが小さいうちは無くても動きますが、後から張り忘れて遅くなる方が厄介です。
第三に、週次で VACUUM と PRAGMA optimize を回すこと。取り込みと削除を繰り返すとページが断片化します。無人スケジュールの週末枠に1行足しておくだけで、ファイルが不必要に膨らむのを防げます。ログの保存側の設計は「無人エージェントの実行ログを、ディスクを溢れさせずに残す 」に、JSON Lines を途中で壊さず読む注意点は「JSON Lines を部分行で壊さず読む 」に分けて書いています。
# 週次メンテ(無人スケジュールの週末枠に追加)
sqlite3 runs.db 'PRAGMA optimize; VACUUM;'
まとめ
まずは手元の logs/*.jsonl に対して、上の ingest.sh を1回だけ流してみてください。取り込みが終わったら、タスク別失敗率のクエリを1本叩く。それだけで、これまで「なんとなく不安定」だった無人ランが、数えられる対象に変わります。
私自身、ログを集計に変えてからは朝のログ読みがほぼ消えました。まだ改善の途中ではありますが、同じように無人ランのログを持て余している方の役に立てば嬉しいです。お読みいただきありがとうございました。