ある朝、4サイト分の更新ログを開いて、そのうち1つだけ記事が増えていないことに気づきました。タスクの実行結果は「成功」と記録されています。けれど、どのコネクタが遅かったのか、途中でどのツール呼び出しが失敗して握りつぶされたのかは、どこにも残っていませんでした。
原因を探るのに、その日は1時間以上かかりました。結局、あるMCPコネクタがタイムアウト寸前まで遅くなり、リトライで辻褄が合ったために全体としては「成功」で終わっていた、というのが真相でした。遅さの兆候はもっと前から出ていたはずなのに、私はそれを一度も見ていなかったのです。
7/7 に Enterprise 向けのコネクタ可観測性が public beta になり、管理者はコネクタの採用状況・エラー・レイテンシ・使用量を Claude 製品全体で監視できるようになりました。素直にうらやましい機能です。ただ、私のように個人開発でスケジュールタスクを4サイト分回している立場には、その管理ダッシュボードはありません。
ないなら、要点だけ自分で持てばよいのです。ここから先は、1回のツール呼び出しにつき1行を書き足すだけの「ヘルス台帳」から始めて、週次でエラー率とレイテンシに丸め、悪化を音が鳴る形にするところまでを、実際に動くコードで組み立てていきます。
「成功」ログだけでは見えない三つの死角
まず、なぜ実行結果の成否だけでは足りないのかを整理します。私が実際に踏んだ死角は、次の三つでした。
| 死角 | 何が起きるか | 成否ログで見えるか |
|---|---|---|
| 沈黙する失敗 | 個々のツール呼び出しが失敗しても、リトライや後続処理で全体は「成功」になる | 見えない |
| じわじわ悪化するレイテンシ | あるコネクタの応答が週を追って遅くなり、ある日タイムアウトで崩れる | 見えない |
| コネクタ単位の切り分け不能 | 遅い・失敗したのがどのコネクタか特定できず、切り分けに時間を溶かす | 見えない |
共通しているのは、いずれも「1回の実行」という粒度では捉えられないという点です。悪化は複数回の呼び出しにまたがって現れます。だからこそ、成否だけでなく「どのコネクタの、どのツールが、何ミリ秒で、成功したか失敗したか」を1行ずつ残しておく必要があります。集めた後で丸めれば、傾向が見えてきます。
まず1行だけ残す — 追記型のヘルス台帳
最初の一歩は驚くほど地味です。ツール呼び出しを1つの関数で包み、結果をJSON Lines(1行1レコード)で追記するだけ。既存のスケジュールタスクに数行を足すだけで始められます。
# health-ledger.sh — 1回のMCPツール呼び出しを1行で記録する
LEDGER="${LEDGER:-$HOME/ops/mcp-health.jsonl}"
mkdir -p "$(dirname "$LEDGER")"
record_call() {
# 使い方: record_call <connector> <tool> -- <実行するコマンド...>
local connector="$1" tool="$2"; shift 2
[ "$1" = "--" ] && shift
local start_ms end_ms status err latency
start_ms=$(date +%s%3N) # GNU date のミリ秒エポック
if "$@"; then
status="ok"; err=""
else
status="fail"; err="exit_$?" # 失敗したコマンドの終了コードを残す
fi
end_ms=$(date +%s%3N)
latency=$(( end_ms - start_ms ))
printf '{"ts":"%s","connector":"%s","tool":"%s","latency_ms":%d,"status":"%s","err":"%s"}\n' \
"$(TZ=Asia/Tokyo date -Iseconds)" "$connector" "$tool" "$latency" "$status" "$err" \
>> "$LEDGER"
}呼び出し側は、いつものコマンドを record_call で包むだけです。
source health-ledger.sh
record_call github issue_list -- gh issue list --repo me/app --limit 20
record_call stripe recent_charges -- node scripts/fetch-charges.mjs
record_call notion sync_pages -- node scripts/notion-sync.mjsここで大事なのは、成功したときも必ず1行残すことです。失敗だけを記録すると、分母が分からずエラー率を計算できません。呼び出しの総数があってはじめて「10回中1回失敗」という比率になり、傾向として意味を持ちます。
date +%s%3N は GNU date のミリ秒表記です。macOS の標準 date では使えないため、ローカルで試す場合は秒精度(date +%s)に落とすか、gdate を使ってください。スケジュールタスクの実行環境は Linux なので、そのままで動きます。
週次で丸める — エラー率とp95レイテンシ
台帳が溜まってきたら、コネクタ単位に丸めます。ここで見たいのは平均レイテンシではなくp95です。平均は、ときどき混じる遅い呼び出しをならして隠してしまいます。実際に体感の悪化を作るのは、上位数パーセントの遅い呼び出しのほうです。
#!/usr/bin/env python3
# rollup.py — ヘルス台帳をコネクタ単位のエラー率とp95レイテンシに丸める
import json, sys, collections
def percentile(values, p):
if not values:
return 0
s = sorted(values)
k = (len(s) - 1) * p
lo = int(k)
hi = min(lo + 1, len(s) - 1)
return round(s[lo] + (s[hi] - s[lo]) * (k - lo)) # 線形補間
emit_json = "--json" in sys.argv
rows = collections.defaultdict(lambda: {"total": 0, "fail": 0, "lat": []})
for line in sys.stdin:
line = line.strip()
if not line:
continue
r = json.loads(line)
b = rows[r["connector"]]
b["total"] += 1
if r["status"] != "ok":
b["fail"] += 1
b["lat"].append(r["latency_ms"])
if emit_json:
for name, b in sorted(rows.items()):
print(json.dumps({
"connector": name, "calls": b["total"],
"err_pct": round(100 * b["fail"] / b["total"], 1),
"p50": percentile(b["lat"], 0.5),
"p95": percentile(b["lat"], 0.95),
}, ensure_ascii=False))
else:
print(f'{"connector":<12}{"calls":>7}{"err%":>8}{"p50":>9}{"p95":>9}')
for name, b in sorted(rows.items()):
err = 100 * b["fail"] / b["total"]
print(f'{name:<12}{b["total"]:>7}{err:>7.1f}%'
f'{percentile(b["lat"],0.5):>7}ms{percentile(b["lat"],0.95):>7}ms')集計はパイプで流すだけです。
cat ~/ops/mcp-health.jsonl | python3 rollup.py出力はこうなります。
connector calls err% p50 p95
github 84 1.2% 240ms 910ms
notion 42 0.0% 180ms 430ms
stripe 28 7.1% 320ms 4200ms
この3行を見た瞬間に、朝のあの1時間が要らなくなります。stripe のエラー率が飛び抜けて高く、p95 が4秒を超えている。切り分けは終わっていて、あとは stripe への呼び出しだけを見に行けばいい。台帳を持つ前と後で、問題に触れるまでの距離がまるで違うのです。
悪化を「音が鳴る」形にする — しきい値とベースライン比較
表を毎週目で見るのは、続きません。人間が見なくても悪化だけが浮かび上がるように、しきい値を決めて前週と比べます。ここでは二つの基準を置きます。エラー率が一定を超えたら、あるいはp95が前週の一定倍を超えたら知らせる、というものです。
#!/usr/bin/env python3
# regress.py — 今週の集計を前週ベースラインと比べ、悪化だけを知らせる
import json, sys
THRESHOLDS = {"err_pct": 5.0, "p95_ratio": 1.5} # エラー率5%超 or p95が1.5倍
def load(path):
with open(path) as f:
return {r["connector"]: r
for r in (json.loads(l) for l in f if l.strip())}
cur = load(sys.argv[1]) # 今週の集計(rollup.py --json の出力)
base = load(sys.argv[2]) # 前週のベースライン
alerts = []
for name, c in cur.items():
if c["err_pct"] > THRESHOLDS["err_pct"]:
alerts.append(f'{name}: エラー率 {c["err_pct"]:.1f}%'
f'(しきい値 {THRESHOLDS["err_pct"]}%)')
b = base.get(name)
if b and b["p95"] > 0 and c["p95"] / b["p95"] >= THRESHOLDS["p95_ratio"]:
alerts.append(f'{name}: p95 {b["p95"]}ms→{c["p95"]}ms'
f'({c["p95"] / b["p95"]:.1f}倍)')
if alerts:
print("⚠️ 悪化を検知しました")
for a in alerts:
print(" -", a)
sys.exit(1) # 非ゼロ終了でスケジュールタスク側に失敗を伝える
print("✅ しきい値内です")運用への組み込みは、集計を保存して比較するだけです。
# 今週分を集計してJSONで保存
cat ~/ops/mcp-health.jsonl | python3 rollup.py --json > ~/ops/summary-cur.jsonl
# 前週ベースラインと比較(悪化があれば非ゼロ終了)
python3 regress.py ~/ops/summary-cur.jsonl ~/ops/summary-base.jsonl \
|| echo "要確認: 今週のコネクタ健康状態に悪化あり"
# 問題なければ今週を次回のベースラインに繰り上げる
cp ~/ops/summary-cur.jsonl ~/ops/summary-base.jsonlp95_ratio を絶対値ではなく前週との比にしているのには理由があります。コネクタごとに素の速さは違うので、固定のミリ秒しきい値だと、速いコネクタには甘く、遅いコネクタには厳しくなります。「そのコネクタ自身の平常時と比べてどれだけ悪化したか」で見るほうが、誤検知も見逃しも減ります。この考え方は、無人パイプラインで古い入力を弾く発想とよく似ていて、古い入力で無音劣化させない — 無人パイプラインの入力フレッシュネス契約でも触れています。
何を測り、何を測らないか
計器は増やすほど良い、というものではありません。個人運用で台帳を続けるコツは、意思決定につながる指標だけを持つことです。私が測る・測らないの線引きは、いまのところこうしています。
| 測る | 測らない |
|---|---|
| コネクタ単位のエラー率(分母=総呼び出し数つき) | 1回ごとの成否だけ(傾向にならない) |
| p95レイテンシと前週比 | 平均レイテンシ(遅い外れ値を隠す) |
| 失敗時の終了コードやエラークラス | 全リクエスト・全レスポンスの本文(肥大化して読まれない) |
| 週次のベースライン更新 | 秒単位のリアルタイム監視(個人には過剰) |
台帳のもう一つの効能は、失敗したときに慌てないことです。悪化が数字で残っていれば、原因を推測で潰す前に「いつから、どのコネクタが」を事実として確かめられます。無音の成功をどう検知するかについては「成功」と記録されたのに成果がゼロだった — Cowork スケジュールタスクの無音失敗を終了前アサーションで止めるも合わせて読むと、成否と健康状態の両輪が揃います。
始めるのに新しいツールは要りません。今夜のスケジュールタスクのツール呼び出しを1つだけ record_call で包んで、明日の朝、台帳に1行増えていることを確かめる。まずはそこからで十分です。私自身、最初はたった1コネクタ・1行から始めました。見えるようになってはじめて、次に何を直すべきかが静かに分かってきます。
台帳ひとつで、失われかけた朝の時間は戻ってきます。ここまでお付き合いくださり、感謝します。