{"slug": "my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent", "title": "My Cost Monitor Said $234 When the Real Bill Was $48. Then set -e Made It Go Silent for a Week.", "summary": "A developer's cost-monitoring script for Claude Code silently failed twice, once overcounting weekly spend by 4-6x and once going quiet due to set -e, leading to a redesign that prioritizes fail-open behavior so the dashboard never goes silent. The 212-line bash script now displays 'n/a' on errors instead of terminating, ensuring cost anomalies remain visible.", "body_md": "A monitoring script that dies quietly is worse than no monitoring at all. Mine proved it twice: first it reported a 7-day spend of **$234 when ccusage daily said the real number was $48** (a 4–6x overcount from double-summing cumulative log lines, now down to a\n\n`set -euo pipefail`\n\n, a single `ccusage`\n\ntimeout made the whole thing `exit 1`\n\nand my status bar sat `critical`\n\nwithout me noticing. The fix in both cases was the same design decision: `⚫ n/a`\n\n, and exit 0. This post walks through the one script — 212 lines — that runs my cost monitoring today, and the design principle behind it: never let the dashboard go quiet.I went from ¥100k/month as a university student juggling side work up to ¥600k, dropped to zero after a company-side layoff, spent six months building an autonomous Claude Code environment, and now hold ¥1.2M/month in revenue. The core of that operating base is one rule: **the dashboard must never stop**.\n\nThis isn't really a post about dashboards. It's a post about environments.\n\nOnce you start using Claude Code heavily, API cost management becomes a life-or-death issue. Even holding ¥1.2M/month in revenue, Claude Code's metered cost can blow past $3,000 in a single week if you take your eye off it. In the autonomous environment I built, launchd fires a cost-monitoring script every 30 minutes and the result is rendered into my status bar and terminal dashboard.\n\nThe problem is this: **if the monitoring script dies, the whole dashboard dies with it.**\n\n`set -euo pipefail`\n\nlooks robust. It gets recommended as a shell scripting best practice all the time. But the moment the 5-hour-window token calculation trips partway through, the status bar goes blank. The moment `ccusage`\n\ndoesn't respond over the network, the launchd job terminates with an error. The moment the first automated run fires before `cost-log.jsonl`\n\nexists, the script dies on an exception.\n\nThat's the structural problem: **the happy path is all green; what breaks is the error paths and the passage of time.**\n\nDesign it fail-closed — meaning `set -e`\n\nturns every error into script termination — and the dashboard *goes silent* on all of those error paths. Silence looks like \"no problems.\" That's the worst possible UI. You lose the ability to distinguish normal operation from missing data.\n\nDesign it fail-open, and the error paths still display `⚫ n/a`\n\n. \"No data\" and \"healthy\" look different. You glance at the dashboard and immediately know something is off. That's the crux of dashboard design.\n\n**The property you need from a monitoring script isn't accuracy. It's never going quiet.**\n\nWhen you're mass-producing personal projects as a side business, an autonomous environment running alongside AI is a productivity multiplier. But if that environment itself isn't *monitored*, it keeps running while broken. You blow past a $3,000 weekly threshold with the cost anomaly invisible. A fail-open monitoring script is your environment's autoimmune system.\n\nI suspect most readers stop at \"I wrote a script and it runs.\" I did too. But once you hit the mass-production phase, \"it worked at first and then broke without me noticing\" happens constantly. Nothing is more harmful than broken monitoring — that's the angle here.\n\n`~/.claude/scripts/token-budget-advisor.sh`\n\nis a 212-line bash script that calls Python3 internally — a mixed-language setup. The file looks long, but the structure is simple.\n\n```\ntoken-budget-advisor.sh\n│\n├─ [前処理] set -u のみ (-e は外す・fail-open方針)\n│\n├─ [データ源①] ccusage blocks --json   ← 公式カウント (優先)\n│       │\n│       └─ 取得失敗 → CC_OUTPUT_TOK=\"\" のまま続行 (fail-open)\n│\n├─ [データ源②] $HOME/.claude/logs/cost-log.jsonl   ← 自前ログ\n│       │\n│       └─ ファイル不在 → fail_open() → exit 0\n│\n├─ [集計] Python3 heredoc\n│       ├─ 5hウィンドウ: session dedup + ccusage優先マージ\n│       ├─ 7dウィンドウ: weekly cost集計\n│       └─ 直近3d burst判定 (avg > 5 sess/day)\n│\n├─ [判定] 🟢 OK / 🟡 warn / 🔴 critical\n│\n└─ [出力]\n        ├─ --short モード → 1行 \"🟢 OK (5h:XXXk tok $X.X / 7d:$XXX)\"\n        └─ JSON  モード  → 整形済みJSONオブジェクト（全フィールド）\n```\n\nThe key point is that **each layer fails open independently**. If `ccusage`\n\ncan't be read, it proceeds to the Python aggregation. If the Python aggregation comes up empty, it exits through `fail_open()`\n\n. Whichever layer breaks, the design guarantees it doesn't go silent.\n\nLines 15–27 at the top of the script condense the entire design philosophy.\n\n```\nset -u  # -e は外す: fail-open 方針\nLOG=\"$HOME/.claude/logs/cost-log.jsonl\"\nMODE=\"${1:-json}\"\n\n# fail-open ヘルパ\nfail_open() {\n  if [ \"$MODE\" = \"--short\" ]; then\n    echo \"⚫ n/a\"\n  else\n    printf '{\"5h_status\":\"unknown\",\"weekly_status\":\"unknown\",\"advice\":\"%s\"}\\n' \"${1:-no data}\"\n  fi\n  exit 0\n}\n```\n\nThe reason for dropping `set -e`\n\nis stated in a one-line comment: \"fail-open 方針\" (fail-open policy). That's the declaration of design intent.\n\n`fail_open()`\n\ntakes an error-reason string as an argument. In `--short`\n\nmode it prints a single line, `⚫ n/a`\n\n; in JSON mode it emits a minimal JSON object containing `5h_status:\"unknown\"`\n\nand `weekly_status:\"unknown\"`\n\n, then terminates with `exit 0`\n\n. Because it exits zero, both launchd and cron treat it as a normal termination. The dashboard shows `⚫ n/a`\n\n, and a human instantly understands \"some data isn't being collected.\"\n\nThis function gets called in three places.\n\n**1. Log file missing** (line 29):\n\n```\n[ -f \"$LOG\" ] || fail_open \"cost-log.jsonl not found\"\n```\n\nDay one of setup, or when the log path changes. The existence check lives only here.\n\n**2. Python aggregation comes up empty** (lines 203–205):\n\n```\nif [ -z \"$RESULT\" ]; then\n  fail_open \"python aggregation failed\"\nfi\n```\n\nFor when Python throws an error to stderr and leaves stdout empty. Since `2>/dev/null`\n\ndiscards the error output, all bash learns is \"aggregation failed.\"\n\n**3. JSON parse failure** (line 208):\n\n``` python\npython3 -c \"import sys,json; print(json.load(sys.stdin)['_short'])\" 2>/dev/null || fail_open \"json parse failed\"\n```\n\nFor when Python emits malformed JSON. The `||`\n\nfalls through to `fail_open`\n\n.\n\nIn every case, `fail_open`\n\nguards only the points where \"if this trips, downstream output can't be guaranteed.\" This isn't defensive programming that catches every error — it's a design that explicitly protects the minimal set of chokepoints where a failure means no output at all.\n\nThe data sources are a two-stage setup (lines 34–56).\n\n```\nif command -v ccusage >/dev/null 2>&1; then\n  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)\n  if [ -n \"$CC_JSON\" ]; then\n    EXTRACTED=$(printf '%s' \"$CC_JSON\" | python3 -c \"\nimport sys, json\ntry:\n    d = json.load(sys.stdin)\n    active = [b for b in d.get('blocks', []) if b.get('isActive')]\n    if active:\n        b = active[0]\n        tc = b.get('tokenCounts', {}) or {}\n        out = int(tc.get('outputTokens', 0))\n        cost = float(b.get('costUSD', 0))\n        print(f'{out}|{cost}')\n    else:\n        print('|')\nexcept Exception:\n    print('|')\n\" 2>/dev/null || echo \"|\")\n    CC_OUTPUT_TOK=\"${EXTRACTED%|*}\"\n    CC_COST_5H=\"${EXTRACTED#*|}\"\n  fi\nfi\n```\n\n`ccusage blocks --json 2>/dev/null || true`\n\n— throwing errors into `/dev/null`\n\nand falling back to `true`\n\nkeeps the pipeline from stopping. Even in an environment where `ccusage`\n\ndoesn't exist, processing continues with `CC_OUTPUT_TOK`\n\nand `CC_COST_5H`\n\nas empty strings.\n\nInside the inline Python script, `try/except Exception`\n\nswallows all exceptions and prints `|`\n\n(just the separator) on failure. After splitting `EXTRACTED`\n\n, you get `CC_OUTPUT_TOK=\"\"`\n\nand `CC_COST_5H=\"\"`\n\n, and the rest of the processing treats it as \"no ccusage.\"\n\nWhen `ccusage`\n\nis alive, its values take priority over the self-log aggregates as the \"official\" numbers (lines 126–131).\n\n```\n# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)\nown_out_5h = out_5h\nif cc_out is not None and cc_out > 0:\n    out_5h = cc_out\nif cc_cost is not None and cc_cost > 0:\n    cost_5h = cc_cost\n```\n\nOn top of that, the `source_diff_pct`\n\nfield computes the divergence rate against the self-computed aggregate (lines 135–137) and includes in the JSON how far apart the two data sources are.\n\n```\ndiff_pct = None\nif cc_out is not None and own_out_5h > 0:\n    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)\n```\n\nThis is for debugging, but it's also early detection for bugs in the self-log aggregation logic. In fact, when this `source_diff_pct`\n\nexceeded 20%, I discovered a session double-counting bug in `cost-log.jsonl`\n\n.\n\nThe `--short`\n\nmode, meant for dashboard integration, narrows output to a single line. The actual output format is defined at line 196 of the Python heredoc.\n\n```\n\"_short\": f\"{icon} {label} (5h:{out_5h/1000:.0f}k tok ${cost_5h:.1f} / 7d:${cost_7d:.0f})\",\n```\n\nFor example, healthy looks like `🟢 OK (5h:342k tok $1.2 / 7d:$48)`\n\n, and a warning looks like `🟡 burst (5h:823k tok $4.1 / 7d:$1204)`\n\n. The format assumes it's called by launchd every 30 minutes and embedded in the terminal status bar.\n\nThe icon decision logic is concentrated in lines 173–181.\n\n```\nif s5 == \"critical\":\n    icon, label = \"🔴\", \"cap-near\"\nelif s5 == \"warn\" or sw == \"warn\":\n    icon, label = \"🟡\", \"burst\"\nelif burst:\n    icon, label = \"🟡\", \"burst\"\nelse:\n    icon, label = \"🟢\", \"OK\"\n```\n\nThe actual threshold values live at lines 139–141.\n\n```\nTHRESH_5H_WARN     = 800_000      # output tokens\nTHRESH_5H_CRIT     = 1_200_000\nTHRESH_WEEK_WARN   = 3000         # USD\nTHRESH_SESS_PER_DAY = 5\n```\n\nOutput tokens in a 5-hour block over 800,000 turns it 🟡; over 1,200,000 turns it 🔴. Weekly cost over $3,000 turns it 🟡. If the average session count over the last 3 days exceeds 5, a \"focused work\" flag is raised. That detection isn't a separate alert — it's folded into the advice string (line 169).\n\n```\nif burst:\n    advice_parts.append(f\"直近3d平均 {avg_sess:.1f}sess/day: 集中作業中\")\n```\n\nJSON mode formats output through `python3 -m json.tool`\n\n(line 210). It's for manual inspection and for piping into other scripts. `--short`\n\ntargets launchd's automated invocation, JSON targets a human checking manually — that separation of roles is the essence of the two-mode design.\n\nWhen `--short`\n\nreturns `⚫ n/a`\n\n, running JSON mode by hand puts the error reason in the `advice`\n\nfield.\n\n```\n{\"5h_status\":\"unknown\",\"weekly_status\":\"unknown\",\"advice\":\"python aggregation failed\"}\n```\n\nEven the debugging path from dashboard to JSON mode is self-contained in those two modes.\n\n`latest`\n\ndict\nThe meatiest part of the script is the Python aggregation block at lines 80–124. It reads the file once and throws it away, then reads it again. Let's start with why it's two-pass.\n\nThe spec of `cost-log.jsonl`\n\nis: \"for each (session ID, transcript file) pair, cumulative values are progressively overwritten.\" While Claude Code keeps running within the same session, the running token total up to that moment is appended as a JSONL line every 10 minutes. In other words, if you just sum every line in the file, you add the same cost dozens of times over.\n\nMy first naive implementation did exactly that. A session whose real cost was $0.8 ballooned to $12 — one multiple per log line.\n\nThe corrected code looks like this (lines 99–111).\n\n```\n# cost-log は session_id × transcript ごとに累積値で書かれる仕様。\n# 最新行のみ採用するため、(session_id, transcript) で最終行を取り直す。\nlatest = {}\nwith open(log_path) as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n            t = datetime.datetime.fromisoformat(r[\"ts\"])\n        except Exception:\n            continue\n        key = (r.get(\"session_id\", \"\"), r.get(\"transcript\", \"\"))\n        prev = latest.get(key)\n        if (prev is None) or (t > prev[0]):\n            latest[key] = (t, r)\n```\n\nNote that the key is a `(session_id, transcript)`\n\ntuple. If you key on session_id alone, a different transcript file generated when Claude Code restarts gets overwritten as \"the same session.\" That actually wiped out an entire day of cost data once. Keying on the pair of both fields gives you the right granularity: \"the latest state of the same work context.\"\n\nAfter building this dict, it loops with `for (sid, _tr), (t, r) in latest.items()`\n\n(line 113), deciding whether the timestamp falls within 5h or 7d and aggregating accordingly. The two-pass structure exists because the requirement \"use only the latest line\" can't be satisfied in one pass. To aggregate while reading in a streaming fashion, you'd first have to scan everything to pin down the final line for each key.\n\nThe first loop, still sitting at lines 80–97, is nearly empty.\n\n```\nwith open(log_path) as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n            t = datetime.datetime.fromisoformat(r[\"ts\"])\n        except Exception:\n            continue\n        sid = r.get(\"session_id\", \"\")\n        ...\n        if t >= cutoff_5h:\n            pass  # ← ここが空\n```\n\nIt literally says `pass`\n\n. That's the part where the comment reads \"if the transcript is the same we want to overwrite-aggregate with the latest line → simple summing is fine here.\" The trace of me hesitating mid-rewrite is still sitting there. In reality, the 5-hour window aggregation is also done from the `latest`\n\ndict in the second pass (lines 116–119). The first pass currently only updates `sess_7d_by_day`\n\n, and even that dict isn't used for the final burst determination (the `by_day`\n\ncounter is used instead) — the more you read, the more visible the \"refactor stopped halfway\" evidence becomes.\n\nThis isn't a bug; it's a judgment call that there was no need to break working code. The script runs correctly at 212 lines.\n\nThe implementation that \"composes ccusage data and self-log aggregates with a priority order\" is just 6 lines, at 126–131.\n\n```\n# ccusage の値が有効ならそちらを優先 (transcript 計算より信頼できる)\nown_out_5h = out_5h\nif cc_out is not None and cc_out > 0:\n    out_5h = cc_out\nif cc_cost is not None and cc_cost > 0:\n    cost_5h = cc_cost\n```\n\nStashing the original value in `own_out_5h`\n\nis the important part — it's there so that when `diff_pct`\n\nis computed at lines 134–136, there's something to compare the ccusage value against.\n\n```\ndiff_pct = None\nif cc_out is not None and own_out_5h > 0:\n    diff_pct = round(abs(cc_out - own_out_5h) / max(cc_out, own_out_5h) * 100, 1)\n```\n\nThe `source_diff_pct`\n\nfield is only visible in JSON mode. You don't normally think about it, but when a bug creeps into the self-log logic, you notice the instant it diverges from ccusage by 20% or more. I've found two bugs that way.\n\nIn environments where ccusage isn't usable (e.g. `command -v ccusage`\n\nfails due to a PATH issue), `cc_out`\n\nand `cc_cost`\n\nboth stay `None`\n\n, the priority handling is skipped, and it runs on self-log aggregates alone. Because the \"stages\" of the two-stage fallback are cleanly separated, you can also confirm which source was used via the `ccusage_used`\n\nfield (line 194).\n\nThe way Python is launched at line 59 is slightly unusual.\n\n```\nRESULT=$(python3 - \"$LOG\" \"${CC_OUTPUT_TOK:-}\" \"${CC_COST_5H:-}\" <<'PY' 2>/dev/null\n```\n\n`python3 -`\n\nis the mode that reads a script from stdin. After that, `<<'PY'`\n\npipes a heredoc into stdin. Arguments are passed via `sys.argv`\n\n.\n\nWhy not split it out into a file? For the simplicity of placement: \"everything is contained in one script.\" When I copy this script to another environment, I don't have to separately verify that the Python file exists. Drop one file into `~/.claude/scripts/`\n\nand it works. The launchd plist configuration just points at that single path.\n\nThe important detail is that the heredoc delimiter is wrapped in single quotes as `<<'PY'`\n\n. With an unquoted `<<PY`\n\n, bash variable expansion is applied inside the heredoc. The moment `{}`\n\nor `$HOME`\n\nshows up in the Python code, expansion kicks in and you get a syntax error. I didn't know that, and wrote it as `<<PY`\n\nat first.\n\nWhen I first implemented naive summing over `cost-log.jsonl`\n\n, `7d_cost_usd`\n\ncame out at 4–6x the actual billed amount. Judging by the symptom alone, it looks like \"API costs are exploding.\"\n\nThe first thing I did to verify was check the real billed amount with `ccusage daily`\n\n. It said $48. The script was returning $234.\n\nNext, looking a bit at the contents of `cost-log.jsonl`\n\n, I saw dozens of lines with the same `session_id`\n\nand incrementing `cost_usd`\n\nvalues. If a session is written out every 10 minutes, you get a run of cumulative values like `0.12, 0.24, 0.37, 0.51...`\n\n. Adding all of those up recorded a $0.51 session as $1.24.\n\nEven after identifying \"summing all lines of cumulative values\" as the cause, I hesitated once on the fix strategy. To \"take only the final line\" you have to scan every line once, and the answer changes depending on whether \"final line\" means \"latest timestamp\" or \"last in file order.\" Considering that writes to the file might not always be in chronological order, I settled on an implementation that explicitly compares timestamps (`if (prev is None) or (t > prev[0])`\n\nat lines 107–111).\n\nAfter the fix, `source_diff_pct`\n\nread 2.3%. That wasn't an error — it was normal divergence caused by a difference in calculation basis between the self-log aggregation and ccusage (the output-token counting method differs slightly).\n\nThe first week after registering the script in a launchd plist, the status bar kept emitting `⚫ n/a`\n\nevery single time. Running `~/.claude/scripts/token-budget-advisor.sh --short`\n\nby hand returned `🟢 OK`\n\n. Tracking down the cause took two hours.\n\nThe PATH of the shell launchd starts is `/usr/bin:/bin:/usr/sbin:/sbin`\n\n. It's a completely different thing from the `$PATH`\n\nyou have in your terminal. When `command -v ccusage`\n\nfails, `CC_JSON`\n\nends up empty and the ccusage path is skipped. But that wasn't the problem — the problem was that a *different* script (an older version that depended on ccusage) was written fail-closed and did `exit 1`\n\nthe moment the ccusage command wasn't found.\n\nSince rewriting to this script, it runs off self-log aggregation even when ccusage is outside PATH. If ccusage is found, `ccusage_used: true`\n\n; if not, it stays `ccusage_used: false`\n\nand aggregates from the self-log alone. Neither path produces `⚫ n/a`\n\n.\n\nAs for handling the plist, the root fix is adding nvm's bin path to launchd's EnvironmentKeys. But then the plist needs updating every time the nvm version changes. Keeping a fail-open design that works without ccusage has a lower management cost over the long run.\n\n`set -e`\n\nin one version, and the dashboard was dead for a week\nThe predecessor of this script was a different file. When I first wrote it, it started with `set -euo pipefail`\n\n. Because \"it looks robust.\"\n\nOne Monday morning, the ccusage API returned a timeout. `ccusage blocks --json`\n\nthrew an error to stderr and terminated with exit 1, so bash's `CC_JSON=$(ccusage blocks --json)`\n\ncaught the error and the entire script exited 1. `set -e`\n\n\"worked\" exactly as intended.\n\nAs a result, the dashboard status bar went blank. Blank looks like \"no problems.\" That week, I missed the ccusage API anomaly entirely and kept doing heavy work, and the 5-hour block crossed `critical`\n\ntwice. It wasn't a cost problem — it was a \"I couldn't notice\" problem. A dashboard that *says nothing* is the worst possible warning.\n\nI realized it the following week while chasing down why the status bar had been blank. Looking at launchd's logs (under `~/Library/Logs/`\n\n), there were exit-1 records at the same time every day. It turned out the ccusage timeouts had been happening intermittently over several days.\n\nThis version is the one where I added `2>/dev/null || true`\n\n, dropped `set -e`\n\n, and left only `set -u`\n\n. `|| true`\n\nis the idiom for \"silently turn an error into success,\" but here it carries a clear intent: \"even if ccusage fails, don't stop the script.\" It's not mere defensive programming — it's an explicit statement of the judgment that \"errors at this layer should not be propagated to the dashboard.\"\n\nTwo weeks ago, when I ran JSON mode manually, it showed `\"source_diff_pct\": 28.4`\n\n. ccusage said the 5h output tokens were 580,000; the self-log said 420,000. A 28% divergence is not within margin of error.\n\nAt first I thought \"ccusage must be wrong.\" There's no way a homemade log is more trustworthy than the official tool, but that's what it felt like.\n\nWhen I actually inspected `cost-log.jsonl`\n\nwith `jq`\n\n, I noticed there were a large number of lines with the same `session_id`\n\nunder a different `transcript`\n\npath, and both had nearby timestamps. When Claude Code is shut down once and restarted, a new transcript file is generated — and at that point both the final line of the old transcript and the initial lines of the new one entered the `latest`\n\ndict, double-counting the session's cost.\n\nWithin `key = (r.get(\"session_id\", \"\"), r.get(\"transcript\", \"\"))`\n\n, the `transcript`\n\nfield sometimes didn't exist in older versions of the cost log. In that case `transcript`\n\nbecomes an empty string, and different transcripts get collapsed into the same key `(session_id, \"\")`\n\nwith only the latest line surviving — that's the behavior I *thought* I had, but in some lines the field name for `transcript`\n\nwas `\"transcript_path\"`\n\n. So `r.get(\"transcript\", \"\")`\n\nwas returning an empty string.\n\nThe fix is one line. I changed it to `r.get(\"transcript\") or r.get(\"transcript_path\", \"\")`\n\n. That said, it's a separate fix to this script, and the code shown here still has the old `r.get(\"transcript\", \"\")`\n\n. Without `source_diff_pct`\n\n, I would have noticed that discrepancy far, far later. The practical value of a design that runs two data sources in parallel and reports the divergence rate only really hit me at that moment.\n\n`<<PY`\n\nand `<<'PY'`\n\nand broke Python\nThis is a repeat, but since it actually happened I'm recording it.\n\nWhen I wrote the Python heredoc delimiter as `<<PY`\n\n(unquoted), the `{}`\n\nin `by_day = collections.Counter()`\n\ninside the Python code became a target of bash brace expansion. The error message was `syntax error near unexpected token '}'`\n\n, which reads as nothing other than \"the Python code is broken.\"\n\nPer bash's heredoc spec, if the delimiter isn't quoted, variable expansion, command substitution, and backslash processing all happen inside the heredoc. Any `${...}`\n\nor `$(...)`\n\nsyntax in the Python code gets interpreted by bash. The `()`\n\nin `collections.Counter()`\n\nisn't a problem for bash, but there are cases where dict-literal `{}`\n\nis.\n\nUsing `<<'PY'`\n\n(delimiter in single quotes) disables all bash expansion inside the heredoc. When the Python code mixes variable expansion, braces, and command substitution, a quoted delimiter is the correct answer. This is basic bash knowledge, but working backwards from the symptom \"why did Python break?\" took time.\n\nThe walkthrough above covers \"how it works.\" From here I'll enumerate \"where it breaks.\" I won't repeat the earlier ones (duplicate aggregation, launchd PATH, leftover `set -e`\n\n, `<<PY`\n\nvs `<<'PY'`\n\n). These are the additional places I actually got stuck beyond those.\n\n**Mixing timezone-naive and timezone-aware datetimes.** Line 69's `now = datetime.datetime.now()`\n\nis a naive object with no timezone. Line 84's `t = datetime.datetime.fromisoformat(r[\"ts\"])`\n\nbecomes aware if the `ts`\n\nfield contains `+09:00`\n\n. The moment you compare naive and aware with `t >= cutoff_5h`\n\n, a TypeError flies. Because the entire script discards stderr with `2>/dev/null`\n\n, the Python traceback goes nowhere, RESULT just comes back empty, and all you get is `fail_open \"python aggregation failed\"`\n\n. The dashboard shows `⚫ n/a`\n\n, and even running JSON mode manually gives you nothing but `python aggregation failed`\n\nin the advice field. This is the kind of landmine that detonates the moment an external tool integration changes its `ts`\n\nformat.\n\n** isdigit() is for positive integers only.** Line 63:\n\n`cc_out = int(cc_out_str) if cc_out_str.isdigit() else None`\n\n. `str.isdigit()`\n\nreturns True only for positive integer strings. It's False for empty strings, negative numbers, and floats (`\"1234.5\"`\n\n), so `cc_out`\n\nfalls through to None. If ccusage's API response spec changes to return `outputTokens`\n\nas a float, the script won't stop — it will fall back to the self-log — but you won't notice that the value's source has changed. `source_diff_pct`\n\nbecomes null, which is the only clue.**The first-pass code is effectively empty.** Read lines 90–93 as written and you get: `if t >= cutoff_5h: pass`\n\n. The comment says \"simple summing is fine,\" but it was never updated after migrating to the second-pass `latest`\n\ndict. The actual 5h aggregation is handled at lines 116–119. Anyone reading the code gets confused about \"why `pass`\n\n?\" It's not a bug, it's a trace of a half-finished refactor — but without a comment, it'll cost you an hour of head-scratching.\n\n** sess_7d_by_day is never used.** Line 78 defines\n\n`sess_7d_by_day = collections.defaultdict(set)`\n\nand line 96 writes `sess_7d_by_day[day].add(sid)`\n\n. But the burst determination (lines 157–159) uses the `by_day`\n\ncounter built in the second pass. `sess_7d_by_day`\n\nis never read once before the process ends. At realistic operating scale it's rare for `cost-log.jsonl`\n\nto exceed a few MB, but run it against a large log and that unnecessary set construction consumes memory.** 2>/dev/null kills your debugging.** Line 59's entire Python invocation is wrapped in\n\n`2>/dev/null`\n\n. When the script terminates normally (exit 0) but no data comes back, the only clue is the error string in the advice field. When debugging, temporarily removing `2>/dev/null`\n\nand running it lets you see the Python stack trace. You don't need to remove it in production, but if you don't know this standard move for \"I can't tell why it's failing,\" tracking down the cause of `⚫ n/a`\n\nwill cost you hours.**There's no argument validation for --short.** Line 17:\n\n`MODE=\"${1:-json}\"`\n\n. With no argument it becomes `json`\n\n. The check is a string comparison, `[ \"$MODE\" = \"--short\" ]`\n\n, so passing `short`\n\n(no hyphen) or `-short`\n\n(one hyphen) runs it in JSON mode. Mistype the argument in the launchd plist and the dashboard always gets multi-line JSON back, breaking the parse. You get neither `⚫ n/a`\n\nnor `🟢`\n\n— the status bar displays `{`\n\n. Because the symptom looks similar to the launchd PATH problem, diagnosis gets delayed.**Burst detection is lenient in a fresh environment.** Line 157: `recent_days = sorted(by_day.keys())[-3:]`\n\n. On day one or two of setup there's less than 3 days of data, so `max(1, len(recent_days))`\n\nreturns 2 or 1. With two days of data you get a two-day average; with one day, the judgment is made from a single day. Burst detection errs toward leniency, so it's the safe direction — but it's the cause of \"why am I seeing so many 🟡?\" in the first week.\n\n**The _short key is exposed in JSON mode.** Line 196's\n\n`\"_short\": f\"...\"`\n\nremains included in the JSON output. Because `python3 -m json.tool`\n\nformats and prints every field, external tools parsing the JSON get `_short`\n\nmixed in as an unexpected field. The `_`\n\nprefix is a Python internal-use convention; JSON has no hiding mechanism. There's no functional problem so I've left it, but if you're integrating externally you should `result.pop(\"_short\")`\n\nbefore handing it off.Here are the decision criteria I extracted from this one script and six months of operation. Not \"you should do this,\" but \"here's why the dashboard went silent in a production environment carrying ¥1.2M/month of revenue when I didn't do this.\"\n\n**1. Declare the design philosophy in a one-line comment at the top**\n\n```\nset -u  # -e は外す: fail-open 方針\n```\n\nDropping `set -e`\n\nis a deliberate choice, not an omission. Without the comment, a later reader — future me, or someone in code review — will put `-e`\n\nback \"to harden it.\" A comment on line 1 of the script becomes the spec document for the design philosophy.\n\n**2. Concentrate output-mode branching in one place inside fail_open()**\n\n`fail_open()`\n\nreferences the `MODE`\n\nvariable internally and emits one line for `--short`\n\nor minimal JSON for JSON mode. Having fail_open handle it centrally leaves fewer gaps than branching per-mode at each call site. Passing the error reason as an argument — like `fail_open \"cost-log.jsonl not found\"`\n\n— leaves debugging information in JSON mode's advice field.\n\n**3. For external commands, write both the existence check and the runtime-error swallow**\n\n```\nif command -v ccusage >/dev/null 2>&1; then\n  CC_JSON=$(ccusage blocks --json 2>/dev/null || true)\n```\n\n`command -v`\n\nalone can't protect against \"it exists but timed out at runtime.\" `|| true`\n\nis the explicit expression of the design stance that \"errors at this layer are not propagated to the dashboard.\" Only with both written does fail-open actually hold.\n\n**4. Use two data sources and compute the divergence rate**\n\nRun the official tool (ccusage) and your own log in parallel, and continuously compute the divergence with `source_diff_pct`\n\n(lines 134–136). When one of them breaks, a divergence of 20%+ tells you. With a single source, you enter the state where \"both can be wrong and you'd never know.\" When `source_diff_pct`\n\nexceeded 28%, I discovered the `transcript`\n\nfield key-name mismatch bug.\n\n**5. For cumulative JSONL logs, always take only the latest line, keyed on a pair**\n\nKey on the `(session_id, transcript)`\n\ntuple and keep only the latest line (lines 100–111). Keying on session_id alone erases the separate transcript after a restart. Summing all lines multi-counts cumulative values. If your cost aggregation reads \"5x the real number,\" suspect this first.\n\n**6. Always use <<'PY' for the Python heredoc delimiter**\n\nAn unquoted `<<PY`\n\nruns bash variable expansion inside the heredoc. If your Python code mixes `{}`\n\n, `$HOME`\n\n, or command substitution, you get a syntax error that looks like nothing but \"Python broke.\" Using `<<'PY'`\n\n(single quotes) disables all expansion inside the heredoc.\n\n**7. Assume launchd's PATH problem and design so it works without external commands**\n\nThe PATH of the shell launchd starts is `/usr/bin:/bin:/usr/sbin:/sbin`\n\n. It doesn't include nvm or Homebrew bin paths. If you design so it runs on self-log aggregation even when ccusage is outside PATH, a PATH problem won't take down the entire dashboard. Adding PATH to the launchd plist is the root fix, but it needs updating every time a tool's version changes. Fail-open design has a lower long-term maintenance cost.\n\n**8. Have fail_open terminate with exit 0**\n\nWith `exit 1`\n\n, launchd records the job as an error. If every automated run every 30 minutes gets treated as an error, launchd's execution log fills with noise and the real errors (the script got deleted, etc.) become invisible. A monitoring script's fail-open depends on `exit 0`\n\nmaking launchd treat it as a normal termination.\n\n**9. Limit output to four states**\n\nNarrow it to four: `⚫ n/a`\n\n(no data), `🟢 OK`\n\n, `🟡 burst`\n\n, `🔴 cap-near`\n\n(lines 174–181). Add more than that and the decision logic gets complex, which itself becomes a breeding ground for bugs. Four states are the minimum set that satisfies both \"a human can read it instantly\" and \"the logic stays simple.\"\n\n**10. Collect thresholds as constants at the top of the script**\n\n```\nTHRESH_5H_WARN     = 800_000\nTHRESH_5H_CRIT     = 1_200_000\nTHRESH_WEEK_WARN   = 3000\nTHRESH_SESS_PER_DAY = 5\n```\n\nAs at lines 139–142, gather the decision values as named constants in one place. If magic numbers are scattered through the decision logic, you go hunting through every location every time you tune a threshold. If you're going to adjust thresholds monthly as revenue changes, having the change surface in one place is a hard requirement.\n\n**11. Pair --short and JSON as a debugging path**\n\nWhen `--short`\n\nreturns `⚫ n/a`\n\n, the next action must be obvious. Running JSON mode manually puts the error reason in the advice field (`python aggregation failed`\n\n, `cost-log.jsonl not found`\n\n, etc.). Build them as a pair, so that the moment the dashboard indicates \"something's off,\" exactly one next command is determined.\n\n**12. Standardize timezones on the log-writing side**\n\nMixing naive/aware between `datetime.now()`\n\nand `fromisoformat()`\n\nkills you instantly with a TypeError. If the log writer pins the `ts`\n\nfield to a naive ISO format (e.g. `%Y-%m-%dT%H:%M:%S`\n\n), the aggregation side can compare it safely against `datetime.now()`\n\n. If you're mixing externally generated logs with your own, place a single conversion layer at ingest.\n\n**13. Make dead variables explicit — comment them or delete them**\n\nVariables that get built but never read, like `sess_7d_by_day`\n\n(lines 78 and 96), confuse the humans who read the code later. Delete it if you can; if there's a reason to keep it, write a comment saying \"not currently read because of X.\" The same goes for the first pass's `pass`\n\nblock (lines 91–93). One line saying \"old code after migrating to the second pass\" prevents the confusion.\n\n**14. Understand the limits of isdigit() before using it**\n\nBefore casting a numeric string returned by an external tool's API to an integer, be conscious that `isdigit()`\n\nis for positive integers only. Generically, the `try: int(cc_out_str) except (ValueError, TypeError): None`\n\nform is safer. The current script depends on the assumption that ccusage returns integers, and a spec change silently switches it to the self-log fallback. Conversion logic that depends on external tools lowers maintenance cost when you make the degraded mode explicit before writing it.\n\n`token-budget-advisor.sh`\n\nis 212 lines. That's not big. But the design decisions condensed inside it are dense.\n\nDropping `set -e`\n\nisn't \"abandoning robustness.\" It's the choice to break the chain of \"when this script stops, the dashboard stops too.\" The property you need from a monitoring script is not accuracy but never going quiet. That's the one point I wanted to make through this single script.\n\nIn an autonomous environment carrying ¥1.2M/month, the cost of a dashboard *saying nothing* accumulates in a way that's hard to see. When the ccusage API was timing out for a day, the script with `set -e`\n\nalive kept up a \"silence indistinguishable from healthy operation.\" I found out afterward that the 5-hour block had crossed critical twice that week. It's not that the dashboard kept showing `🟢`\n\n. It just showed nothing at all. Blank and healthy were visually indistinguishable.\n\nFail-open is not \"a design that ignores errors.\" It's \"a design that delivers the existence of an error to a place where a human can see it.\" `⚫ n/a`\n\nis not `🟢`\n\n. It's the minimum output required to keep an abnormal state from looking normal. The script is still running every 30 minutes today. As long as it runs, it doesn't go quiet.\n\nI've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent", "canonical_source": "https://dev.to/bokuwalily/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent-for-a-week-475j", "published_at": "2026-08-22 05:00:06+00:00", "updated_at": "2026-08-22 05:13:34.942397+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["Claude Code", "ccusage", "launchd"], "alternates": {"html": "https://wpnews.pro/news/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent", "markdown": "https://wpnews.pro/news/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent.md", "text": "https://wpnews.pro/news/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent.txt", "jsonld": "https://wpnews.pro/news/my-cost-monitor-said-234-when-the-real-bill-was-48-then-set-e-made-it-go-silent.jsonld"}}