0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🟢 ダッシュボードを止めないfail-open監視スクリプトの作り方

0
Posted at

月10万の大学生時代から掛け持ちで60万まで伸ばし、会社都合の解雇でいちどゼロになってから半年でClaude Code自律環境を組み上げ、今は月商120万を維持している——その運用基盤の核にある「ダッシュボードを絶対に止めない」という設計思想を、実スクリプト1本で解説します。

なぜこの仕組みが効くのか

ダッシュボードの話ではなく、環境の話です。

Claude Codeを使い込むようになると、APIコスト管理が死活問題になります。月商120万を維持しているとはいえ、Claude Codeの従量コストは油断すると1週間で$3,000を超えます。私が組んだ自律環境では、launchdが30分おきにコスト監視スクリプトを叩き、結果をステータスバーとターミナルダッシュボードに表示しています。

問題は「監視スクリプトが死ぬとダッシュボード全体が死ぬ」という点にあります。

set -euo pipefailは一見堅牢に見えます。シェルスクリプトのベストプラクティスとして紹介されることも多い。でも5hウィンドウのtoken計算が途中でコケた瞬間、ステータスバーは空白になります。ccusageがネットワーク応答しなかった瞬間にlaunchdジョブがerrorで終わる。cost-log.jsonlが存在しないまま初日の自動実行が走った瞬間に、スクリプトが例外で落ちる。

これが「正常系は全部green、壊れるのは異常系と時間経過」という構造的な問題です。

fail-closedで設計すると——つまりset -eで全エラーをスクリプト終了に倒すと——これらの異常系でダッシュボードが「沈黙」します。沈黙は「問題なし」に見える。最悪のUIです。正常稼働とデータ欠損が区別できなくなる。

fail-openで設計すると、異常系でも⚫ n/aが表示されます。「データなし」と「正常」は見た目が違う。ダッシュボードを見た瞬間に「何かおかしい」と気づける。これがダッシュボード設計のキモです。

監視スクリプトに求められる性質は「正確さ」ではなく「沈黙しないこと」です。

副業として個人開発を量産するとき、AIと並走する自律環境は生産性の乗数になります。でもその環境自体が「監視されていない」なら、壊れたまま走り続けます。コスト異常が見えていないまま、週次$3,000の閾値を超えていく。fail-openな監視スクリプトは、環境の自己免疫システムです。

読者の多くは「スクリプトを書いて動かした」段階で満足していると思います。私もそうでした。でも量産フェーズに入ると、「最初は動いていたのに気づいたら壊れていた」という事態が頻発します。壊れた監視ほど有害なものはない——それが今回の切り口です。

全体の流れ

スクリプトのアーキテクチャ

~/.claude/scripts/token-budget-advisor.shは212行のbashスクリプトで、内部でPython3を呼び出す混合構成です。ファイルが長く見えますが、構造は単純です。

token-budget-advisor.sh
│
├─ [前処理] set -u のみ (-e は外す・fail-open方針)
│
├─ [データ源①] ccusage blocks --json   ← 公式カウント (優先)
│       │
│       └─ 取得失敗 → CC_OUTPUT_TOK="" のまま続行 (fail-open)
│
├─ [データ源②] $HOME/.claude/logs/cost-log.jsonl   ← 自前ログ
│       │
│       └─ ファイル不在 → fail_open() → exit 0
│
├─ [集計] Python3 heredoc
│       ├─ 5hウィンドウ: session dedup + ccusage優先マージ
│       ├─ 7dウィンドウ: weekly cost集計
│       └─ 直近3d burst判定 (avg > 5 sess/day)
│
├─ [判定] 🟢 OK / 🟡 warn / 🔴 critical
│
└─ [出力]
        ├─ --short モード → 1行 "🟢 OK (5h:XXXk tok $X.X / 7d:$XXX)"
        └─ JSON  モード  → 整形済みJSONオブジェクト(全フィールド)

ポイントは、各レイヤーが独立してfail-openです。ccusageが取れなくてもPython集計に進む。Python集計が空振りしてもfail_open()で終わる。どのレイヤーが壊れても「沈黙しない」設計です。

fail_open() ヘルパを読む

スクリプト冒頭の15〜27行目に、この設計のすべてが凝縮されています。

set -u  # -e は外す: fail-open 方針
LOG="$HOME/.claude/logs/cost-log.jsonl"
MODE="${1:-json}"

# fail-open ヘルパ
fail_open() {
  if [ "$MODE" = "--short" ]; then
    echo "⚫ n/a"
  else
    printf '{"5h_status":"unknown","weekly_status":"unknown","advice":"%s"}\n' "${1:-no data}"
  fi
  exit 0
}

set -eを外した理由はコメントに一行で書いてあります。「fail-open 方針」。ここが設計意図の宣言です。

fail_open()は引数としてエラー理由の文字列を受け取ります。--shortモードなら⚫ n/aの1行を出力し、JSONモードなら5h_status:"unknown"weekly_status:"unknown"を含む最小JSONを吐いて、exit 0で終わります。ゼロ終了なので、launchdもcronも「正常終了」として扱います。ダッシュボードには⚫ n/aが表示され、人間は「何かデータが取れていない」とすぐわかる。

この関数が呼ばれる箇所は3カ所あります。

1. ログファイル不在(29行目):

[ -f "$LOG" ] || fail_open "cost-log.jsonl not found"

セットアップ初日や、ログのパスが変わったとき。存在チェックをここだけで済ませています。

2. Python集計の空振り(203〜205行目):

if [ -z "$RESULT" ]; then
  fail_open "python aggregation failed"
fi

Pythonがstderrにエラーを吐いてstdoutを空にした場合。2>/dev/nullでエラー出力を捨てているので、bashからは「集計失敗」という情報しか伝わりません。

3. JSONパース失敗(208行目):

python3 -c "import sys,json; print(json.load(sys.stdin)['_short'])" 2>/dev/null || fail_open "json parse failed"

Pythonが不正なJSONを吐いた場合。||でfail_openに倒しています。

いずれも「これがコケたら後続出力が保証できない」というポイントだけにfail_openをかけています。全エラーをキャッチする防衛的プログラミングではなく、「ここが死んだら出力不能」という最小の要所を明示的に守る設計です。

ccusage → cost-log.jsonl の多段フォールバック

データソースは2段構成です(34〜56行目)。

if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)
  if [ -n "$CC_JSON" ]; then
    EXTRACTED=$(printf '%s' "$CC_JSON" | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    active = [b for b in d.get('blocks', []) if b.get('isActive')]
    if active:
        b = active[0]
        tc = b.get('tokenCounts', {}) or {}
        out = int(tc.get('outputTokens', 0))
        cost = float(b.get('costUSD', 0))
        print(f'{out}|{cost}')
    else:
        print('|')
except Exception:
    print('|')
" 2>/dev/null || echo "|")
    CC_OUTPUT_TOK="${EXTRACTED%|*}"
    CC_COST_5H="${EXTRACTED#*|}"
  fi
fi

ccusage blocks --json 2>/dev/null || true——エラーを/dev/nullに捨ててtrueに倒すことでパイプラインを止めません。ccusageが存在しない環境でも、CC_OUTPUT_TOKCC_COST_5Hが空文字列のまま処理が続きます。

Pythonインラインスクリプト内でもtry/except Exceptionで全例外を握り、失敗時は|(区切り文字のみ)を出力します。EXTRACTEDのsplit後にCC_OUTPUT_TOK=""CC_COST_5H=""となり、以降の処理では「ccusageなし」として扱われます。

ccusageが生きている場合、その値はself-logの集計値より「公式」として優先されます(126〜131行目)。

# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)
own_out_5h = out_5h
if cc_out is not None and cc_out > 0:
    out_5h = cc_out
if cc_cost is not None and cc_cost > 0:
    cost_5h = cc_cost

さらにsource_diff_pctフィールドで自前集計との乖離率を計算し(135〜137行目)、両データソースがどれだけズレているかをJSONに含めます。

diff_pct = None
if cc_out is not None and own_out_5h > 0:
    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)

これはデバッグ用ですが、自前ログの集計ロジックにバグがあった場合の早期検知になります。実際、このsource_diff_pctが20%を超えたとき、cost-log.jsonlのセッション重複集計バグを発見しました。

--short / --json の2モード設計

ダッシュボード統合用の--shortモードは、出力を1行に絞ります。実際の出力フォーマットはPython heredocの196行目に定義されています。

"_short": f"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})",

例えば正常時は🟢 OK (5h:342k tok $1.2 / 7d:$48)、警告時は🟡 burst (5h:823k tok $4.1 / 7d:$1204)となります。launchdから30分おきに呼ばれ、ターミナルのステータスバーに埋め込まれる前提のフォーマットです。

アイコンの判定ロジックは173〜181行目に集中しています。

if s5 == "critical":
    icon, label = "🔴", "cap-near"
elif s5 == "warn" or sw == "warn":
    icon, label = "🟡", "burst"
elif burst:
    icon, label = "🟡", "burst"
else:
    icon, label = "🟢", "OK"

しきい値の実数値は139〜141行目です。

THRESH_5H_WARN     = 800_000      # output tokens
THRESH_5H_CRIT     = 1_200_000
THRESH_WEEK_WARN   = 3000         # USD
THRESH_SESS_PER_DAY = 5

5hブロックのoutput tokenが800,000を超えると🟡、1,200,000を超えると🔴。週間コストが$3,000を超えると🟡。直近3日の平均セッション数が5を超えると「集中作業フラグ」が立ちます。集中作業の検知は別のアラートではなくアドバイス文字列に含まれます(169行目)。

if burst:
    advice_parts.append(f"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中")

JSONモードはpython3 -m json.toolで整形して出力します(210行目)。手動確認や他スクリプトとのパイプ接続用です。--shortがlaunchdの自動呼び出し向け、JSONが人間の手動確認向け——この役割の分離が2モード設計の本質です。

--shortモードで⚫ n/aが返ってきたとき、JSONモードで手動実行するとadviceフィールドにエラー理由が入ります。

{"5h_status":"unknown","weekly_status":"unknown","advice":"python aggregation failed"}

ダッシュボードからJSONモードへのデバッグ動線まで、2モードで完結しています。

実装の詳細

コスト計算の核心:2パス集計とlatest辞書

スクリプトの中でいちばん読み応えがある箇所は、80〜124行目のPython集計ブロックです。一度ファイルを読んで捨て、もう一度読み直しています。2パスになっている理由から入りましょう。

cost-log.jsonlの仕様は「セッションIDとtranscriptファイルのペアに対して、累積値を逐次上書きしていく」形式です。Claude Codeが同一セッション内で動き続けると、10分おきにその時点までのトークン合計がJSONL行として追記されます。つまり、ファイルをそのまま全行合算すると同じコストを何十回も足すことになります。

最初に書いた素朴な実装は、まさしくそれをやっていました。1セッションの実コスト$0.8が、ログ行数分の$12に化けていた。

修正後のコードはこうなっています(99〜111行目)。

# cost-log は session_id × transcript ごとに累積値で書かれる仕様。
# 最新行のみ採用するため、(session_id, transcript) で最終行を取り直す。
latest = {}
with open(log_path) as f:
    for line in f:
        try:
            r = json.loads(line)
            t = datetime.datetime.fromisoformat(r["ts"])
        except Exception:
            continue
        key = (r.get("session_id", ""), r.get("transcript", ""))
        prev = latest.get(key)
        if (prev is None) or (t > prev[0]):
            latest[key] = (t, r)

(session_id, transcript)のタプルをキーにしている点に注目してください。session_idだけをキーにすると、Claude Codeを再起動したときに生成される別のtranscriptファイルが「同じセッション」として上書きされてしまいます。実際にそれで一度、1日分のコストがまるごと消えました。2つのフィールドのペアをキーにすることで「同じ作業コンテキストの最新状態」という粒度になります。

このdictを構築したあと、for (sid, _tr), (t, r) in latest.items()でループし(113行目)、タイムスタンプが5h以内か7d以内かを判定して集計します。2パス構成なのは、「最新行だけを使いたい」という要件が1パスで満たせないからです。streamingeで読みながら集計するには、先にすべてを走査して各キーの最終行を確定させる必要があります。

スクリプトの80〜97行目に残っている最初のループは、ほぼ空になっています。

with open(log_path) as f:
    for line in f:
        try:
            r = json.loads(line)
            t = datetime.datetime.fromisoformat(r["ts"])
        except Exception:
            continue
        sid = r.get("session_id", "")
        ...
        if t >= cutoff_5h:
            pass  # ← ここが空

passと書いてあります。コメントに「transcript 同一の場合は最新行で上書き集計したい → ここは単純合算で OK」と書いてある部分です。書き換えの途中で迷った跡がそのまま残っています。実際には、5hウィンドウの集計も2パス目のlatest辞書から行っています(116〜119行目)。1パス目は現在、sess_7d_by_dayの更新だけをしていますが、その辞書自体も最終的なバースト判定には使われておらず(by_dayカウンタの方が使われる)、読むほどに「リファクタ途中で止まった」形跡が見えます。

これはバグではなく、「動いているコードを壊す必要がなかった」という判断です。スクリプトは212行で正しく動いています。

ccusage優先マージの実装パターン

ccusageのデータとself-logの集計値を「優先度付きで合成する」実装は、126〜131行目のわずか6行です。

# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)
own_out_5h = out_5h
if cc_out is not None and cc_out > 0:
    out_5h = cc_out
if cc_cost is not None and cc_cost > 0:
    cost_5h = cc_cost

own_out_5hに元の値を退避させているのが重要です。次の134〜136行目でdiff_pctを計算するとき、ccusageの値と比較するためです。

diff_pct = None
if cc_out is not None and own_out_5h > 0:
    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)

このsource_diff_pctフィールドは、JSONモードでのみ見えます。普段は意識しませんが、セルフログのロジックにバグが入ったとき、ccusageと20%以上乖離した瞬間に気づけます。実際にそれで2回バグを発見しました。

ccusageが使えない環境(PATH問題などでcommand -v ccusageが失敗する場合)では、cc_outcc_costはどちらもNoneのまま優先処理がスキップされ、self-logの集計値だけで動きます。2段フォールバックの「段」が明確に分かれているので、どのソースが使われているかもccusage_usedフィールドで確認できます(194行目)。

PythonをHeredocで呼ぶ理由

59行目のPython起動方法が少し特殊です。

RESULT=$(python3 - "$LOG" "${CC_OUTPUT_TOK:-}" "${CC_COST_5H:-}" <<'PY' 2>/dev/null

python3 -はstdinからスクリプトを読むモードです。その後に<<'PY'でheredocをstdinに流しています。引数はsys.argvとして渡す形です。

なぜファイルに切り出さないのか。「スクリプト1本で完結する」という配置の単純さのためです。このスクリプトを他の環境にコピーするとき、Pythonファイルの存在を別途確認する必要がない。~/.claude/scripts/に1ファイル置けば動く。launchdのplist設定もそのパスを1つ指すだけです。

ヒアドキュメントの区切り文字を<<'PY'とシングルクォートで囲んでいる点が重要です。クォートなしの<<PYにするとbashの変数展開がheredoc内に適用されます。Pythonコードに{}$HOMEが登場した瞬間に展開が起きて構文エラーになります。これを知らずに最初は<<PYで書いていました。


私が詰まった話

セッション重複でコストが「5倍」に見えた

最初にcost-log.jsonlの単純合算を実装したとき、7d_cost_usdが実際の請求額の4〜6倍で出ていました。症状だけ見ると「API料金が爆発している」ように見えます。

確認手順として最初にやったのはccusage dailyで実際の請求額を見ることです。$48と出ていました。スクリプトは$234を返していました。

次にcost-log.jsonlの中身を少し見ると、同じsession_idが同じcost_usdの増加値で何十行も並んでいました。たとえばあるセッションが10分おきに書き込まれていると、0.12, 0.24, 0.37, 0.51...という累積値が並ぶわけです。これを全部足すと$0.51のセッションが$1.24として計上されていました。

原因が「累積値の全行合算」だと分かってからも、修正方針で一度迷っています。「最終行だけ取る」ためには全行を一度走査しないといけないし、「最終行」の定義が「タイムスタンプが最新」なのか「ファイル内の順序が最後」なのかで答えが変わります。ファイルへの書き込みが必ずしも時系列順でない可能性を考え、タイムスタンプを明示的に比較する実装(107〜111行目のif (prev is None) or (t > prev[0]))に落ち着きました。

修正後にsource_diff_pctを見ると2.3%でした。誤差ではなくセルフログ集計とccusageの計算基準の違い(output tokenのカウント方式が微妙に違う)による正常な乖離でした。

launchdでccusageが見つからなかった日

launchdのplistでスクリプトを登録した最初の週、ステータスバーが毎回⚫ n/aを出し続けました。手動で~/.claude/scripts/token-budget-advisor.sh --shortを叩くと🟢 OKが返る。原因追跡に2時間かかりました。

launchdが起動するシェルのPATHは/usr/bin:/bin:/usr/sbin:/sbinです。ターミナルで叩くときの$PATHとはまったく別物です。command -v ccusageが失敗するとCC_JSONが空になり、ccusage経路はスキップされます。問題はそこではなく、別のスクリプト(ccusageに依存していた古い版)がfail-closeで書かれていて、ccusageのコマンドが見つからない瞬間にexit 1していたことでした。

このスクリプトに書き換えてからは、ccusageがPATH外にあっても自前ログ集計で動きます。ccusageが見つかればccusage_used: trueになり、見つからなければccusage_used: falseのままself-logだけで集計されます。どちらのパスも⚫ n/aにはなりません。

plistへの対処としては、launchdのEnvironmentKeysにnvmのbinパスを追記することが根本解決です。ただしnvmのバージョンが変わるたびにplistの更新が必要になります。ccusageがなくても動くfail-open設計にしておく方が、長期的に管理コストが低い。

set -eを外し忘れたバージョンで1週間ダッシュボードが死んでいた

このスクリプトの前身は別のファイルでした。最初に書いたときはset -euo pipefailで始まっていました。「堅牢に見えるから」です。

ある月曜の朝、ccusageのAPIがタイムアウトを返した日がありました。ccusage blocks --jsonがstderrにエラーを吐いてexit 1で終わったため、bashのCC_JSON=$(ccusage blocks --json)がエラーを受けてスクリプト全体がexit 1しました。set -eが意図通りに「働いた」わけです。

結果、ダッシュボードのステータスバーが空白になりました。空白は「問題なし」に見えます。その週、私はccusageのAPI異常を見逃したまま重い作業を続け、5hブロックが2回criticalを超えていました。コスト的な問題ではなく、「気づけなかった」という問題です。ダッシュボードが「何も言わない」ことが最悪の警告です。

翌週にステータスバーが空白だった原因を追っていて気づきました。launchdのログ(~/Library/Logs/以下)を見ると、毎日同じ時刻にexit 1の記録が残っていました。ccusageのタイムアウトが数日にわたって断続的に発生していたことが分かりました。

2>/dev/null || trueを追加し、set -eを外してset -uだけ残したのがこの版です。|| trueはエラーを「黙って成功に倒す」ための慣用句ですが、ここでは「ccusageが失敗してもスクリプトを止めない」という明確な意図があります。単なる防衛的プログラミングではなく、「このレイヤーのエラーはダッシュボードに伝えるべきではない」という判断の表明です。

source_diff_pctが28%で飛んで来て自分のバグに気づいた

2週間前、手動でJSONモードを叩いたとき"source_diff_pct": 28.4が出ていました。ccusageは5hのoutput tokenを580,000と言い、self-logは420,000と言っていました。28%の乖離は誤差の範囲ではありません。

最初は「ccusageがおかしい」と思いました。公式ツールより自前ログの方が信頼できるはずがないのに、なんとなくそう感じました。

実際にcost-log.jsonljqで精査したところ、同じsession_idの別のtranscriptパスの行が大量にあり、どちらも時刻が近いことに気づきました。Claude Codeを一度終了して再起動したとき、新しいtranscriptファイルが生成されるのですが、その際に旧transcriptの最終行と新transcriptの初期行が両方latest辞書に入り、セッションのコストが二重計上されていました。

key = (r.get("session_id", ""), r.get("transcript", ""))のうち、transcriptフィールドが古い版のcost-logには存在しないことがありました。そのケースでtranscriptが空文字列になり、異なるtranscriptが同じキー(session_id, "")に集約されて最新行だけが残る——という挙動をしているつもりでしたが、一部の行でtranscriptフィールドのキー名が"transcript_path"になっていました。r.get("transcript", "")が空文字列を返していたわけです。

修正は1行です。r.get("transcript") or r.get("transcript_path", "")に変えました。ただし、それはこのスクリプトの別個の修正であり、ここに載っているコードではまだ古いr.get("transcript", "")のままです。source_diff_pctがなければこのずれに気づくのはずっと後になっていたでしょう。2つのデータソースを並走させて乖離率を出す設計の実用的な価値は、このときはじめて実感しました。

<<PY<<'PY'を混同してPythonが壊れた

再掲になりますが、実際に発生したので記録として書きます。

Python heredocの区切り文字を<<PY(クォートなし)で書いたとき、Pythonコード内のby_day = collections.Counter(){}がbashのブレース展開の対象になりました。エラーメッセージはsyntax error near unexpected token '}'で、「Pythonのコードが壊れている」としか読めませんでした。

bashのheredocの仕様として、区切り文字をクォートしない場合は変数展開・コマンド置換・バックスラッシュ処理がheredoc内部で行われます。Pythonコードに${...}$(...)``のような構文があれば、すべてbashに解釈されます。collections.Counter()()はbashの問題になりませんが、辞書リテラルの{}`が問題になるケースはあります。

<<'PY'(区切り文字をシングルクォート)にすることで、heredoc内のすべてのbash展開が無効化されます。Pythonコードに変数展開・ブレース・コマンド置換が混在しているとき、クォートあり区切り文字が正解です。これはbashの基本知識ですが、「なぜPythonが壊れたのか」という症状から逆算するのに時間がかかりました。

つまずきポイント

ここまでの解説で「どう動くか」は見えました。ここからは「どこで壊れるか」を網羅します。前段(重複集計・launchd PATH・set -e残し・<<PYvs<<'PY')は繰り返しません。そこから先で実際に詰まった箇所を追加列挙します。

  • タイムゾーンnaiveとawareの混在。69行目のnow = datetime.datetime.now()はタイムゾーンなしのnaiveオブジェクトです。84行目のt = datetime.datetime.fromisoformat(r["ts"])は、tsフィールドに+09:00が含まれているとawareになります。naiveとawareをt >= cutoff_5hで比較した瞬間にTypeErrorが飛びます。スクリプト全体が2>/dev/nullでstderrを捨てているため、PythonのトレースバックはどこにもでもなくRESULTが空になり、fail_open "python aggregation failed"が返るだけです。ダッシュボードは⚫ n/aを表示し、JSONモードで手動実行してもadviceフィールドのpython aggregation failedしか手がかりがない。外部ツール連携でtsフォーマットが変わった瞬間に爆発する型の地雷です。

  • isdigit() は正整数専用。63行目:cc_out = int(cc_out_str) if cc_out_str.isdigit() else Nonestr.isdigit()は正の整数文字列にのみTrueを返します。空文字・負数・浮動小数点("1234.5")でFalseになりcc_outがNoneに倒れます。ccusageのAPIレスポンス仕様が変わってoutputTokensを浮動小数点で返すようになった場合、スクリプトは止まらずself-logにフォールバックしますが、値のソースが変わっていることには気づけません。source_diff_pctがNullになるのでそこが唯一の手がかりです。

  • 1パス目のコードが実質空。90〜93行目をそのまま読むとこうなっています:if t >= cutoff_5h: pass。コメントには「単純合算でOK」とありますが、2パス目のlatest辞書に移行した後に更新されていません。実際の5h集計は116〜119行目が担っています。コードを読む人間が「なぜpassなのか」と混乱します。バグではなくリファクタ途中の跡ですが、コメントがなければ1時間は悩みます。

  • sess_7d_by_day は使われていない。78行目にsess_7d_by_day = collections.defaultdict(set)が定義され、96行目でsess_7d_by_day[day].add(sid)が書かれています。しかしバースト判定(157〜159行目)は2パス目で構築するby_dayカウンターを使っています。sess_7d_by_dayは一度も読まれないまま処理が終わります。現実の運用規模ではcost-log.jsonlが数MBを超えることはまれですが、大きなログで走らせると不要なセット構築がメモリを消費します。

  • 2>/dev/null がデバッグを殺す。59行目のPython呼び出し全体に2>/dev/nullがかかっています。スクリプトが正常終了(exit 0)しているのにデータが取れないとき、原因はadviceフィールドのエラー文字列だけです。デバッグ時は一時的に2>/dev/nullを外して走らせるとPythonのスタックトレースが見えます。本番で外す必要はありませんが、「なぜ失敗しているか分からない」ときの定石を知っていないと、⚫ n/aの原因追跡に数時間かかります。

  • --short の引数チェックがない。17行目:MODE="${1:-json}"。引数がなければjsonになります。判定は[ "$MODE" = "--short" ]の文字列一致なので、short(ハイフンなし)や-short(ハイフン1つ)を渡すとJSONモードで動きます。launchdのplistで引数を誤記すると、ダッシュボードが常に複数行のJSONを返してパースが壊れます。⚫ n/a🟢も出ず、ステータスバーに{が表示されます。症状がlaunchd PATH問題と見た目が似ているため、原因特定が遅れます。

  • バースト判定が新規環境では甘くなる。157行目:recent_days = sorted(by_day.keys())[-3:]。セットアップ初日や2日目はデータが3日分未満のため、max(1, len(recent_days))が2や1を返します。2日分のデータで2日平均、1日分なら1日分だけで判定されます。バースト判定が甘くなる方向なので安全側ですが、初週に「なぜか🟡が多い」の原因になります。

  • _shortキーはJSONモードで露出する。196行目の"_short": f"..."はJSON出力に含まれたままです。python3 -m json.toolが全フィールドを整形して出力するため、外部ツールがJSONをパースすると_shortが予期しないフィールドとして混入します。_プレフィックスはPythonの内部用慣例ですが、JSONには隠蔽機能がありません。動作に問題はないため残していますが、外部連携するならresult.pop("_short")してから渡すべきです。


ベストプラクティス

このスクリプト1本と半年の運用から抽出した判断基準を並べます。「こうすべき」ではなく「こうしなかったためにダッシュボードが月商120万の生産環境で沈黙した理由」です。

1. 設計思想を先頭の1行コメントで宣言する

set -u  # -e は外す: fail-open 方針

set -eを外すのは書き忘れではなく意図的な選択です。コメントがないと、後から読んだ自分(あるいはコードレビューの誰か)が「堅牢化のために-eを追加しよう」と戻します。スクリプト1行目のコメントが設計思想の仕様書になります。

2. fail_open() は出力モード別の分岐を1か所に集める

fail_open()内でMODE変数を参照し、--shortなら1行・JSONなら最小JSONを吐きます。呼び出し元でモード別に分岐するより、fail_openが一元的に処理する方が漏れがありません。fail_open "cost-log.jsonl not found"のように引数でエラー理由を渡すことで、JSONモードのadviceフィールドにデバッグ情報が残ります。

3. 外部コマンドは存在確認と実行時エラーの握り潰しを両方書く

if command -v ccusage >/dev/null 2>&1; then
  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)

command -vだけでは「存在するが実行時にタイムアウトした」ケースを防げません。|| trueは「このレイヤーのエラーはダッシュボードに伝えない」という設計の明示的な表現です。両方書いて初めてfail-openが成立します。

4. データソースは2段にして乖離率を出す

公式ツール(ccusage)と自前ログを並走させ、source_diff_pct(134〜136行目)で乖離率を常時計算します。どちらかが壊れたとき20%以上の乖離でそれと分かります。シングルソースでは「両方おかしくても気づかない」状態に入ります。source_diff_pctが28%を超えたとき、transcriptフィールドのキー名不一致バグを発見しました。

5. JSONL累積ログは必ずペアキーで最新行だけ採用する

(session_id, transcript) のタプルをキーにして最新行だけを残します(100〜111行目)。session_idだけをキーにすると再起動後の別transcriptが消えます。全行合算すると累積値の多重計上になります。コスト集計で「実際の5倍」が出た場合、まずここを疑います。

6. Python heredocの区切り文字は必ず <<'PY' にする

クォートなしの<<PYはheredoc内でbash変数展開が走ります。Pythonコードに{}$HOME・コマンド置換が混在すると構文エラーになり、「Pythonが壊れた」としか見えません。<<'PY'(シングルクォート)にすればheredoc内の展開が全て無効化されます。

7. launchdのPATH問題を前提に「外部コマンドなしでも動く」設計にする

launchdが起動するシェルのPATHは/usr/bin:/bin:/usr/sbin:/sbinです。nvmやHomebrewのbinパスは含まれません。ccusageがPATH外にあっても自前ログ集計で動く設計にしておけば、PATHの問題でダッシュボード全体が落ちません。launchd側のplistにPATHを追加する方法は根本解決ですが、ツールのバージョンが変わるたびに更新が必要です。fail-open設計の方が長期的な保守コストが低い。

8. fail_openexit 0 で終わらせる

exit 1にするとlaunchdがジョブをエラーとして記録します。30分おきの自動実行が全てエラー扱いになると、launchdの実行ログがノイズで埋まり、本当のエラー(スクリプト自体が消えた等)が見えなくなります。監視スクリプトのfail-openはexit 0でlaunchdに「正常終了」として扱わせることが前提です。

9. 出力を4状態に絞る

⚫ n/a(データなし)・🟢 OK🟡 burst🔴 cap-nearの4つに絞ります(174〜181行目)。これ以上増やすと判定ロジックが複雑になり、それ自体がバグの温床になります。4状態は「人間が一瞬で読める」と「ロジックのシンプルさ」を両立する最小セットです。

10. しきい値は定数にまとめてスクリプト上部に集中させる

THRESH_5H_WARN     = 800_000
THRESH_5H_CRIT     = 1_200_000
THRESH_WEEK_WARN   = 3000
THRESH_SESS_PER_DAY = 5

139〜142行目のように、判定値は名前つきの定数で一か所に集めます。魔法数字が判定ロジック内に散らばると、しきい値を調整するたびに全箇所を探します。月商の変化に合わせてしきい値を毎月調整するなら、変更箇所が1か所に集まっていることが必須条件です。

11. --short と JSON の2モードはデバッグ動線で対にする

--short⚫ n/aを返したとき、次のアクションが自明でなければなりません。JSONモードを手動実行するとadviceフィールドにエラー理由が入ります(python aggregation failedcost-log.jsonl not found等)。ダッシュボードが「おかしい」と示した瞬間に、次のコマンドが1つに決まる設計をペアで作っておきます。

12. タイムゾーンはログ書き込み側で統一する

datetime.now()fromisoformat()のnaive/aware混在はTypeErrorで即死します。ログ書き込み側でtsフィールドを常にnaiveのISO形式(例:%Y-%m-%dT%H:%M:%S)に固定しておけば、集計側のdatetime.now()と安全に比較できます。外部ツールが生成するログと自前ログを混在させる場合は、取り込み時に変換するレイヤーを1か所置きます。

13. dead variableはコメントか削除で明示する

sess_7d_by_day(78・96行目)のように構築されるが読まれない変数は、後からコードを読む人間を混乱させます。削除できれば削除、残す理由があればコメントで「〇〇のため現時点では読まれていない」と書きます。1パス目のpassブロック(91〜93行目)も同様です。「2パス目に移行した後の旧コード」と1行書くだけで混乱が防げます。

14. isdigit() の限界を把握してから使う

外部ツールのAPIが返す数値文字列を整数にキャストする前に、isdigit()が正整数専用であることを意識します。汎用的にはtry: int(cc_out_str) except (ValueError, TypeError): Noneの形の方が安全です。現状のスクリプトはccusageが整数を返す前提に依存しており、仕様変更でサイレントにself-logフォールバックに切り替わります。外部ツール依存の変換処理は、デグレードモードを明確にしてから書くことが保守コストを下げます。


まとめ

token-budget-advisor.shは212行です。大きくありません。でも中に凝縮されている設計判断は密度があります。

set -eを外すことは「堅牢さを捨てる」のではありません。「このスクリプトが止まったとき、ダッシュボードも止まる」という連鎖を断ち切る選択です。監視スクリプトに求められる性質は正確さより沈黙しないことです。それがこのスクリプト1本を通じて言いたかった1点です。

月商120万の自律環境では、ダッシュボードが「何も言わない」ことのコストが見えにくい形で積み上がります。ccusageのAPIが1日タイムアウトしていたとき、set -eが生きていたスクリプトは「正常稼働と見分けのつかない沈黙」をし続けました。その週、5hブロックが2回criticalを超えていたことを後から知りました。ダッシュボードが🟢を出し続けていたわけではありません。ただ、何も出していなかった。空白と正常が見た目で区別できなかった。

fail-openは「エラーを無視する設計」ではありません。「エラーの存在を人間が見える場所に届ける設計」です。⚫ n/a🟢ではありません。異常状態を正常に見せないための最小の出力です。スクリプトは今日も30分おきに動いています。動いている限り、沈黙しません。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?