@tsa-group/claude-usage 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -0
- package/dist/cli.js +104 -0
- package/dist/creds.js +103 -0
- package/dist/daemon.js +217 -0
- package/dist/events.js +448 -0
- package/dist/health.js +95 -0
- package/dist/install.js +263 -0
- package/dist/paths.js +57 -0
- package/dist/upload.js +188 -0
- package/dist/usage.js +112 -0
- package/dist/util.js +73 -0
- package/dist/version.js +24 -0
- package/dist/view.js +182 -0
- package/package.json +22 -0
package/dist/events.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本機資料 → wire schema 的**唯一權威映射層**(token_events 與 limit_snapshots 兩條流)。
|
|
3
|
+
*
|
|
4
|
+
* Per-event token extraction from Claude Code JSONL — the ONE authoritative extractor.
|
|
5
|
+
* 其他模組一律 import 這裡,不要各自再寫一份:這個專案已經為「同一份映射存兩份」
|
|
6
|
+
* 付過三次代價(三份 extractor、兩份 severity 映射、schema 與 .proto 各一份),
|
|
7
|
+
* 其中一次讓 stg.v_sessions.message_count 高估了 6.73 倍(4568 vs 實際 679)。
|
|
8
|
+
*
|
|
9
|
+
* 四個實測依據:
|
|
10
|
+
* 1. 去重鍵是 (message_id, requestId) 複合鍵。單用 message_id 會漏重試產生的
|
|
11
|
+
* 重複;實測 2,121 筆原始只有 550 個 distinct 複合鍵(高估 4.06 倍)。
|
|
12
|
+
* 2. cache_creation 必須拆 5m / 1h。1h 是 base x2、5m 是 base x1.25,實測用量幾乎
|
|
13
|
+
* 全落 1h,合成一個數字套 5m 費率會低估約 37.5%。
|
|
14
|
+
* 3. subagent 在 projects 底下深一層的 subagents/agent-*.jsonl,且 isSidechain=true
|
|
15
|
+
* 只出現在那些檔案裡,目錄走訪是發現機制、isSidechain 是佐證。
|
|
16
|
+
* 4. usage.iterations[] 與 top-level 相等(1505/1505 筆長度為 1),top-level 是
|
|
17
|
+
* rollup,永不加總 iterations。
|
|
18
|
+
*
|
|
19
|
+
* 增量游標(per-file byte offset,存 ~/.claude-usage/cursor.json):
|
|
20
|
+
* JSONL 是 append-only,所以記「這個檔讀到第幾個 byte」就能精確只讀新增部分,
|
|
21
|
+
* 不需要時間戳 lookback(時間戳 lookback 會因為 JSONL 非嚴格時間遞增而漏資料,
|
|
22
|
+
* 或為了保險而重送整個 lookback 窗)。兩個必須處理的邊界:
|
|
23
|
+
* - 尾端半行:daemon 可能在 Claude Code 正在寫一行的中間讀檔,只吃到最後一個
|
|
24
|
+
* 換行為止,offset 不越過不完整的行。
|
|
25
|
+
* - 檔案縮小(compaction/改寫):size < offset 時整檔重讀,產生的重複由 BQ 的
|
|
26
|
+
* stg 去重 view 吸收。
|
|
27
|
+
*
|
|
28
|
+
* 為什麼到處是 nn(...):Python 的 dict.get() 缺鍵回 None,序列化成 "k": null;
|
|
29
|
+
* JS 的 obj?.k 缺鍵回 undefined,而 JSON.stringify 會把整個 key 丟掉。對 server 來說
|
|
30
|
+
* 兩者都是 NULL,但差異測試會整排噴紅、也讓「這個欄位到底有沒有被送過」變得說不
|
|
31
|
+
* 清楚。統一轉成 null,wire payload 就與 Python 版逐欄一致。
|
|
32
|
+
*/
|
|
33
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { cursorPath, healthPath, projectsDir, snapPath } from "./paths.js";
|
|
36
|
+
import { h16, readJson, writeJsonAtomic } from "./util.js";
|
|
37
|
+
const nn = (v) => (v === undefined ? null : v);
|
|
38
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
// ───────────────────────────── 檔案發現 ─────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* 主 session 檔 + subagent 檔。subagent 深一層,且只有那裡有 isSidechain。
|
|
42
|
+
*
|
|
43
|
+
* 刻意手寫走訪而不用 fs.globSync:後者要 Node 22+,而這兩個 pattern 深度固定、
|
|
44
|
+
* 十幾行就寫完。為此把同事的 Node 版本下限抬高兩個大版本並不划算。
|
|
45
|
+
*/
|
|
46
|
+
export function files() {
|
|
47
|
+
const root = projectsDir();
|
|
48
|
+
const out = [];
|
|
49
|
+
let projects;
|
|
50
|
+
try {
|
|
51
|
+
projects = readdirSync(root, { withFileTypes: true })
|
|
52
|
+
// glob 的 * 不匹配以 . 開頭的名字,readdirSync 會。不濾掉的話兩個實作看到的
|
|
53
|
+
// 檔案集合就不同,而讀取順序會影響去重的「第一筆勝出」。
|
|
54
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
55
|
+
.map((d) => d.name);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
for (const p of projects) {
|
|
61
|
+
const pdir = join(root, p);
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(pdir, { withFileTypes: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
// projects/<proj>/*.jsonl -- 主 session
|
|
71
|
+
if (e.isFile() && e.name.endsWith(".jsonl") && !e.name.startsWith(".")) {
|
|
72
|
+
out.push(join(pdir, e.name));
|
|
73
|
+
}
|
|
74
|
+
// projects/<proj>/<sub>/subagents/agent-*.jsonl -- subagent
|
|
75
|
+
if (e.isDirectory() && !e.name.startsWith(".")) {
|
|
76
|
+
const sub = join(pdir, e.name, "subagents");
|
|
77
|
+
try {
|
|
78
|
+
for (const f of readdirSync(sub, { withFileTypes: true })) {
|
|
79
|
+
if (f.isFile() && f.name.startsWith("agent-") && f.name.endsWith(".jsonl")) {
|
|
80
|
+
out.push(join(sub, f.name));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// 沒有 subagents 目錄是常態
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out.sort();
|
|
91
|
+
}
|
|
92
|
+
export const emptyCursor = () => ({ files: {}, snapshots: 0 });
|
|
93
|
+
export function loadCursor() {
|
|
94
|
+
const c = readJson(cursorPath());
|
|
95
|
+
if (!isObj(c))
|
|
96
|
+
return emptyCursor();
|
|
97
|
+
// v1 是扁平形狀 {path: offset},要遷移。不遷移的話既有安裝會把全部歷史重讀一次。
|
|
98
|
+
if (!("files" in c)) {
|
|
99
|
+
const f = {};
|
|
100
|
+
for (const [k, v] of Object.entries(c)) {
|
|
101
|
+
if (typeof v === "number" && Number.isInteger(v))
|
|
102
|
+
f[k] = v;
|
|
103
|
+
}
|
|
104
|
+
return { files: f, snapshots: 0 };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
files: isObj(c["files"]) ? c["files"] : {},
|
|
108
|
+
snapshots: typeof c["snapshots"] === "number" ? c["snapshots"] : 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function saveCursor(cur) {
|
|
112
|
+
// key 排序讓檔案 diff 穩定(游標檔會被人打開來看)。atomic rename:半寫的游標
|
|
113
|
+
// 會讓下一輪漏資料或重讀全部歷史。
|
|
114
|
+
const sorted = {
|
|
115
|
+
files: Object.fromEntries(Object.entries(cur.files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
|
|
116
|
+
snapshots: cur.snapshots,
|
|
117
|
+
};
|
|
118
|
+
writeJsonAtomic(cursorPath(), sorted);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 讀 append-only JSONL 從 offset 之後的完整行。
|
|
122
|
+
* 這是整個增量機制的唯一原語(session 檔與 usage_snapshots.jsonl 共用)。
|
|
123
|
+
* 兩個邊界都在這裡處理,錯了是靜默漏資料 -- 見 test/events.test.ts case 3/4/5。
|
|
124
|
+
*/
|
|
125
|
+
export function tailFile(path, offset = 0) {
|
|
126
|
+
let size;
|
|
127
|
+
try {
|
|
128
|
+
size = statSync(path).size;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { rows: [], offset: null };
|
|
132
|
+
}
|
|
133
|
+
if (offset > size)
|
|
134
|
+
offset = 0; // 檔案被改寫/截斷,整檔重讀,重複交給 view
|
|
135
|
+
if (offset === size)
|
|
136
|
+
return { rows: [], offset };
|
|
137
|
+
let buf;
|
|
138
|
+
let fd;
|
|
139
|
+
try {
|
|
140
|
+
fd = openSync(path, "r");
|
|
141
|
+
buf = Buffer.allocUnsafe(size - offset);
|
|
142
|
+
const got = readSync(fd, buf, 0, size - offset, offset);
|
|
143
|
+
if (got < buf.length)
|
|
144
|
+
buf = buf.subarray(0, got);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { rows: [], offset };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (fd !== undefined) {
|
|
151
|
+
try {
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// 關檔失敗不該影響已讀到的資料
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const nl = buf.lastIndexOf(0x0a);
|
|
160
|
+
if (nl < 0)
|
|
161
|
+
return { rows: [], offset }; // 沒有完整行,offset 不前進,下輪再看
|
|
162
|
+
const consumed = buf.subarray(0, nl + 1);
|
|
163
|
+
const rows = [];
|
|
164
|
+
// 換行不可能出現在 UTF-8 多位元組序列中間,所以先整段解碼再切行是安全的。
|
|
165
|
+
for (const line of consumed.toString("utf8").split("\n")) {
|
|
166
|
+
if (!line.trim())
|
|
167
|
+
continue;
|
|
168
|
+
try {
|
|
169
|
+
const o = JSON.parse(line);
|
|
170
|
+
if (isObj(o))
|
|
171
|
+
rows.push(o);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
continue; // 壞行跳過,不讓一行毀掉整批
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { rows, offset: offset + consumed.length };
|
|
178
|
+
}
|
|
179
|
+
/** cursor=null 表示全掃;給物件則只讀各檔新增部分。 */
|
|
180
|
+
function scan(cursor) {
|
|
181
|
+
const incremental = cursor !== null;
|
|
182
|
+
const cur = cursor ?? {};
|
|
183
|
+
const newCursor = {};
|
|
184
|
+
const rows = [];
|
|
185
|
+
for (const path of files()) {
|
|
186
|
+
const start = incremental ? (cur[path] ?? 0) : 0;
|
|
187
|
+
const { rows: got, offset } = tailFile(path, start);
|
|
188
|
+
if (offset === null)
|
|
189
|
+
continue; // 檔案消失,從游標移除
|
|
190
|
+
newCursor[path] = offset;
|
|
191
|
+
rows.push(...got);
|
|
192
|
+
}
|
|
193
|
+
return { rows, newCursor };
|
|
194
|
+
}
|
|
195
|
+
/** JSONL row 轉 token_events 列,或 null(非推論列)。欄位名即 wire schema 的欄位名。 */
|
|
196
|
+
export function toEvent(o) {
|
|
197
|
+
const m = isObj(o["message"]) ? o["message"] : {};
|
|
198
|
+
const u = m["usage"];
|
|
199
|
+
const mid = m["id"];
|
|
200
|
+
if (!isObj(u) || typeof mid !== "string" || !mid)
|
|
201
|
+
return null;
|
|
202
|
+
const ts = o["timestamp"];
|
|
203
|
+
if (typeof ts !== "string" || !ts)
|
|
204
|
+
return null;
|
|
205
|
+
const model = (nn(m["model"]) ?? null);
|
|
206
|
+
if (model === "<synthetic>")
|
|
207
|
+
return null; // Claude Code 內部佔位,非真實推論
|
|
208
|
+
const cc = isObj(u["cache_creation"]) ? u["cache_creation"] : {};
|
|
209
|
+
const std = isObj(u["server_tool_use"]) ? u["server_tool_use"] : {};
|
|
210
|
+
const otd = isObj(u["output_tokens_details"]) ? u["output_tokens_details"] : {};
|
|
211
|
+
return {
|
|
212
|
+
message_id: mid,
|
|
213
|
+
request_id: nn(o["requestId"]),
|
|
214
|
+
session_id: (o["sessionId"] !== undefined
|
|
215
|
+
? nn(o["sessionId"])
|
|
216
|
+
: nn(o["session_id"])),
|
|
217
|
+
ts,
|
|
218
|
+
model,
|
|
219
|
+
input_tokens: nn(u["input_tokens"]),
|
|
220
|
+
output_tokens: nn(u["output_tokens"]), // 已含 thinking_tokens
|
|
221
|
+
thinking_tokens: nn(otd["thinking_tokens"]), // output 子集,不可相加
|
|
222
|
+
cache_read_tokens: nn(u["cache_read_input_tokens"]),
|
|
223
|
+
cache_write_5m_tokens: nn(cc["ephemeral_5m_input_tokens"]),
|
|
224
|
+
cache_write_1h_tokens: nn(cc["ephemeral_1h_input_tokens"]),
|
|
225
|
+
web_search_requests: nn(std["web_search_requests"]),
|
|
226
|
+
web_fetch_requests: nn(std["web_fetch_requests"]),
|
|
227
|
+
service_tier: nn(u["service_tier"]),
|
|
228
|
+
speed: nn(u["speed"]),
|
|
229
|
+
effort: nn(o["effort"]),
|
|
230
|
+
// 這兩欄是 bool(...) 不是 get(...):缺鍵時是 false,不是 null。
|
|
231
|
+
// 寫成 null 會讓 BQ 的 NOT COALESCE(is_api_error, FALSE) 行為改變。
|
|
232
|
+
is_sidechain: Boolean(o["isSidechain"]),
|
|
233
|
+
is_api_error: Boolean(o["isApiErrorMessage"]),
|
|
234
|
+
entrypoint: nn(o["entrypoint"]),
|
|
235
|
+
cc_version: nn(o["version"]),
|
|
236
|
+
attribution_skill: nn(o["attributionSkill"]),
|
|
237
|
+
attribution_mcp_server: nn(o["attributionMcpServer"]),
|
|
238
|
+
attribution_mcp_tool: nn(o["attributionMcpTool"]),
|
|
239
|
+
project_hash: h16(o["cwd"]), // 不送原始路徑(可能含客戶名)
|
|
240
|
+
git_branch: nn(o["gitBranch"]),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const scopeModelOf = (l) => {
|
|
244
|
+
const sc = isObj(l["scope"]) ? l["scope"] : {};
|
|
245
|
+
const md = isObj(sc["model"]) ? sc["model"] : {};
|
|
246
|
+
return nn(md["display_name"]);
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* usage_snapshots.jsonl 一列轉 limit_snapshots 列。欄位名即 wire schema 的欄位名。
|
|
250
|
+
*
|
|
251
|
+
* user_id / device_id / ingest_at 由 server 端填。client 不自報身份,那是 device_key
|
|
252
|
+
* 推導出來的,也是唯一擋得住偽造的方向。
|
|
253
|
+
*
|
|
254
|
+
* severity 純量欄位:severity 是 Anthropic 自己的判定(normal|warning|critical),
|
|
255
|
+
* 門檻未公開,我們不能用 utilization% 自己算,只能照抄。原本這個值只在 limits[]
|
|
256
|
+
* 陣列裡,BQ 端要 UNNEST 才讀得到,Looker Studio 幾乎等於讀不到。在這裡攤平成純量,
|
|
257
|
+
* 映射就只有這一份。
|
|
258
|
+
*
|
|
259
|
+
* limits[] 照樣整包送,未來 Anthropic 新增 limit kind 時不必改 schema 也不丟資料。
|
|
260
|
+
* 實測固定 3 筆(session / weekly_all / weekly_scoped),scope 是物件不是純量,
|
|
261
|
+
* 只有 weekly_scoped 帶 scope.model.display_name。
|
|
262
|
+
*/
|
|
263
|
+
export function toLimitSnapshot(o) {
|
|
264
|
+
const u = isObj(o["usage"]) ? o["usage"] : {};
|
|
265
|
+
const fh = isObj(u["five_hour"]) ? u["five_hour"] : {};
|
|
266
|
+
const sd = isObj(u["seven_day"]) ? u["seven_day"] : {};
|
|
267
|
+
const rawLimits = Array.isArray(u["limits"]) ? u["limits"] : [];
|
|
268
|
+
const byKind = new Map();
|
|
269
|
+
for (const l of rawLimits) {
|
|
270
|
+
if (isObj(l) && typeof l["kind"] === "string")
|
|
271
|
+
byKind.set(l["kind"], l);
|
|
272
|
+
}
|
|
273
|
+
const sess = byKind.get("session") ?? {};
|
|
274
|
+
const wall = byKind.get("weekly_all") ?? {};
|
|
275
|
+
const wsc = byKind.get("weekly_scoped") ?? {};
|
|
276
|
+
return {
|
|
277
|
+
// severity / weekly_scoped 純量(攤平自 limits[])
|
|
278
|
+
five_hour_severity: nn(sess["severity"]),
|
|
279
|
+
seven_day_severity: nn(wall["severity"]),
|
|
280
|
+
// weekly_scoped 是只存在於 limits[] 的第三個指標(per-model 週上限):
|
|
281
|
+
// 它不在 usage.five_hour 也不在 usage.seven_day,攤平之前沒有純量路徑讀得到。
|
|
282
|
+
weekly_scoped_utilization: nn(wsc["percent"]),
|
|
283
|
+
weekly_scoped_severity: nn(wsc["severity"]),
|
|
284
|
+
weekly_scoped_model: scopeModelOf(wsc),
|
|
285
|
+
weekly_scoped_resets_at: nn(wsc["resets_at"]),
|
|
286
|
+
captured_at: nn(o["collected_at"]),
|
|
287
|
+
five_hour_utilization: nn(fh["utilization"]),
|
|
288
|
+
five_hour_resets_at: nn(fh["resets_at"]),
|
|
289
|
+
seven_day_utilization: nn(sd["utilization"]),
|
|
290
|
+
seven_day_resets_at: nn(sd["resets_at"]),
|
|
291
|
+
extra_usage_enabled: nn((isObj(u["extra_usage"]) ? u["extra_usage"] : {})["is_enabled"]),
|
|
292
|
+
limits: rawLimits.filter(isObj).map((l) => ({
|
|
293
|
+
kind: nn(l["kind"]),
|
|
294
|
+
group: nn(l["group"]),
|
|
295
|
+
percent: nn(l["percent"]),
|
|
296
|
+
severity: nn(l["severity"]),
|
|
297
|
+
resets_at: nn(l["resets_at"]),
|
|
298
|
+
is_active: nn(l["is_active"]),
|
|
299
|
+
scope_model: scopeModelOf(l),
|
|
300
|
+
scope_surface: nn((isObj(l["scope"]) ? l["scope"] : {})["surface"]),
|
|
301
|
+
})),
|
|
302
|
+
http_status: nn(o["http_status"]),
|
|
303
|
+
seat_tier: nn(o["tier"]), // keychain rateLimitTier,utilization% 的分母
|
|
304
|
+
subscription: nn(o["subscription"]),
|
|
305
|
+
cred_source: nn(o["cred_source"]), // keychain | file
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* usage_snapshots.jsonl 的增量讀取。
|
|
310
|
+
* 舊版每次 report 重送全部快照(每約 16 分鐘一筆,一年 3 萬筆每 tick 全送)。
|
|
311
|
+
* 同一個 tailFile 機制解掉。
|
|
312
|
+
*/
|
|
313
|
+
export function snapshotsIncremental(offset = 0) {
|
|
314
|
+
const { rows, offset: off } = tailFile(snapPath(), offset);
|
|
315
|
+
return {
|
|
316
|
+
rows: rows.filter((o) => o["collected_at"]).map(toLimitSnapshot),
|
|
317
|
+
offset: off === null ? offset : off,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
// ─────────────────────────── 去重與公開 API ───────────────────────────
|
|
321
|
+
/**
|
|
322
|
+
* rows 轉 event 列表。dedupe=true 時做 (message_id, request_id) 去重。
|
|
323
|
+
*
|
|
324
|
+
* client 端去重只在本批次內有效。同一個複合鍵可能散落在不同批次(實測重複可達
|
|
325
|
+
* 9 次),跨批次去重是 BQ stg view 的職責。client 端去重是省頻寬的優化,
|
|
326
|
+
* 不是正確性保證。
|
|
327
|
+
*/
|
|
328
|
+
function dedupeRows(rows, since = null, dedupe = true) {
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const o of rows) {
|
|
332
|
+
const e = toEvent(o);
|
|
333
|
+
if (e === null)
|
|
334
|
+
continue;
|
|
335
|
+
if (dedupe) {
|
|
336
|
+
// 分隔用的空白不會出現在 uuid 型的 id 裡,所以不會有 key 碰撞。
|
|
337
|
+
const key = `${e.message_id} ${e.request_id ?? ""}`;
|
|
338
|
+
if (seen.has(key))
|
|
339
|
+
continue;
|
|
340
|
+
seen.add(key);
|
|
341
|
+
}
|
|
342
|
+
// since 過濾在去重之後:被 since 濾掉的事件仍然佔用了它的去重鍵。
|
|
343
|
+
// 對調順序會讓「同鍵但較晚的那筆」意外通過,與 Python 版行為不同。
|
|
344
|
+
if (since && e.ts <= since)
|
|
345
|
+
continue;
|
|
346
|
+
out.push(e);
|
|
347
|
+
}
|
|
348
|
+
// ISO-8601 字串比較即時間序(同 Python)。JS 的 sort 自 ES2019 起保證穩定。
|
|
349
|
+
out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/** 全掃。給檢視用。dedupe=false 保留原始重複。 */
|
|
353
|
+
export function extract(since = null, dedupe = true) {
|
|
354
|
+
return dedupeRows(scan(null).rows, since, dedupe);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 只讀游標之後的新增行。
|
|
358
|
+
* 呼叫端負責在上傳成功後才 saveCursor()。先存游標再上傳失敗會永久丟掉那批資料
|
|
359
|
+
* (本機 JSONL 是唯一來源,沒有第二次機會)。
|
|
360
|
+
*/
|
|
361
|
+
export function extractIncremental(cursorFiles) {
|
|
362
|
+
const cur = cursorFiles ?? loadCursor().files;
|
|
363
|
+
const { rows, newCursor } = scan(cur);
|
|
364
|
+
return { events: dedupeRows(rows), newCursor };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 由 per-event 資料回推 session 摘要,不需要再掃一次檔案。
|
|
368
|
+
*
|
|
369
|
+
* session_id 跨 compaction/resume 不穩定(實測兩檔同 started_at、message_count
|
|
370
|
+
* 1053 vs 1120),session 數會高估。增量模式下 message_count 只是這批的計數,
|
|
371
|
+
* BQ 的 stg.v_sessions 已改為只取維度、量測全部從 v_token_events 推導。
|
|
372
|
+
*/
|
|
373
|
+
export function sessionsFrom(events) {
|
|
374
|
+
const agg = new Map();
|
|
375
|
+
for (const e of events) {
|
|
376
|
+
const sid = e.session_id;
|
|
377
|
+
if (!sid)
|
|
378
|
+
continue;
|
|
379
|
+
let s = agg.get(sid);
|
|
380
|
+
if (!s) {
|
|
381
|
+
s = {
|
|
382
|
+
session_id: sid,
|
|
383
|
+
started_at: e.ts,
|
|
384
|
+
ended_at: e.ts,
|
|
385
|
+
message_count: 0,
|
|
386
|
+
models: [],
|
|
387
|
+
_models: new Set(),
|
|
388
|
+
project_hash: e.project_hash,
|
|
389
|
+
git_branch: e.git_branch,
|
|
390
|
+
cc_version: e.cc_version,
|
|
391
|
+
entrypoint: e.entrypoint,
|
|
392
|
+
};
|
|
393
|
+
agg.set(sid, s);
|
|
394
|
+
}
|
|
395
|
+
if (e.ts < s.started_at)
|
|
396
|
+
s.started_at = e.ts;
|
|
397
|
+
if (e.ts > s.ended_at)
|
|
398
|
+
s.ended_at = e.ts;
|
|
399
|
+
s.message_count += 1;
|
|
400
|
+
if (e.model)
|
|
401
|
+
s._models.add(e.model);
|
|
402
|
+
}
|
|
403
|
+
return [...agg.values()].map(({ _models, ...s }) => ({ ...s, models: [..._models].sort() }));
|
|
404
|
+
}
|
|
405
|
+
// ─────────────────────────── health 投影 ───────────────────────────
|
|
406
|
+
/**
|
|
407
|
+
* health.json 的哪些 key 會上報。用白名單而不是整包送:health.json 是 daemon 的
|
|
408
|
+
* 私有狀態檔,未來加的欄位(例如本機除錯用的路徑)不該自動流到 server 去。
|
|
409
|
+
* 欄位名與 server 端 client_health 的欄位一對一。
|
|
410
|
+
*/
|
|
411
|
+
export const HEALTH_FIELDS = [
|
|
412
|
+
"last_tick_at",
|
|
413
|
+
"ticks",
|
|
414
|
+
"last_sample_at",
|
|
415
|
+
"last_sample_status",
|
|
416
|
+
"last_ok_at",
|
|
417
|
+
"last_ok_at_backfilled",
|
|
418
|
+
"consecutive_failures",
|
|
419
|
+
"first_failure_at",
|
|
420
|
+
"last_upload_state",
|
|
421
|
+
"last_error",
|
|
422
|
+
"unhealthy",
|
|
423
|
+
"warning",
|
|
424
|
+
];
|
|
425
|
+
/**
|
|
426
|
+
* ~/.claude-usage/health.json 轉 client_health 列(或 null)。
|
|
427
|
+
*
|
|
428
|
+
* 這一條流的存在理由:fact 表上「機器沒開」和「採樣壞了」長得一模一樣,兩者都是
|
|
429
|
+
* 「沒有新資料」。2026-08-22 那次就是後者 -- token 過期後 daemon 每 5 分鐘照跑、
|
|
430
|
+
* 照寫 log,17.6 小時一筆快照都沒進來,而使用者完全無感。所以 client 每個 tick 都
|
|
431
|
+
* 送這個心跳,就算沒有任何新事件也送(見 upload.report)。
|
|
432
|
+
*
|
|
433
|
+
* warning 是 client 端已經算好的人話原因(health.healthWarning),不是讓 server
|
|
434
|
+
* 再推一次。判斷邏輯只該有一份,而它需要本機才有的上下文。
|
|
435
|
+
*/
|
|
436
|
+
export function healthPayload() {
|
|
437
|
+
const h = readJson(healthPath());
|
|
438
|
+
if (!isObj(h) || Object.keys(h).length === 0)
|
|
439
|
+
return null;
|
|
440
|
+
const out = {};
|
|
441
|
+
for (const k of HEALTH_FIELDS) {
|
|
442
|
+
// 值為 null/undefined 的欄位省略(server 端就是 NULL)。
|
|
443
|
+
// 注意 false 與 0 必須保留 -- unhealthy=false 是有意義的值。
|
|
444
|
+
if (h[k] !== undefined && h[k] !== null)
|
|
445
|
+
out[k] = h[k];
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
package/dist/health.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon 自身的健康狀態(~/.claude-usage/health.json)。
|
|
3
|
+
*
|
|
4
|
+
* 這整個檔案是 2026-08-22 那次事故的直接產物:token 過期後 daemon 每 5 分鐘照跑、
|
|
5
|
+
* 照寫 log,launchd 顯示 runs=556 / last exit code=0(作業系統層面完全健康),
|
|
6
|
+
* 但 17.6 小時一筆快照都沒進來,而使用者完全無感。
|
|
7
|
+
*
|
|
8
|
+
* 教訓寫在這裡免得再犯:**「背景任務已註冊且 exit 0」不能當成「有資料進來」的
|
|
9
|
+
* 代理指標 —— 必須量測資料本身。**
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { healthPath, snapPath } from "./paths.js";
|
|
13
|
+
import { parseIso, readJson, writeJsonAtomic } from "./util.js";
|
|
14
|
+
/** fetch 的結束碼轉可讀狀態。分開記錄是重點:「token 過期」與「網路壞掉」的處置完全不同。 */
|
|
15
|
+
export const STATUS_BY_CODE = {
|
|
16
|
+
0: "ok",
|
|
17
|
+
2: "token_expired",
|
|
18
|
+
3: "no_token",
|
|
19
|
+
4: "http_error",
|
|
20
|
+
5: "network_error",
|
|
21
|
+
};
|
|
22
|
+
/** 連續失敗幾次 / 幾小時沒成功,就算不健康 */
|
|
23
|
+
export const WARN_FAILURES = 3;
|
|
24
|
+
export const WARN_STALE_HOURS = 6;
|
|
25
|
+
/** usage_snapshots.jsonl 的最後一行(本機最新一筆快照) */
|
|
26
|
+
export function lastSnapshot() {
|
|
27
|
+
const p = snapPath();
|
|
28
|
+
if (!existsSync(p))
|
|
29
|
+
return null;
|
|
30
|
+
let last = null;
|
|
31
|
+
try {
|
|
32
|
+
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
33
|
+
if (line.trim())
|
|
34
|
+
last = line;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (!last)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(last);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function loadHealth() {
|
|
50
|
+
const h = readJson(healthPath()) ?? {};
|
|
51
|
+
// health.json 是後來才加的。首次執行時若沒有 last_ok_at,從既有快照回填 --
|
|
52
|
+
// 否則會對一台其實採了 100+ 筆的機器報「從未成功採樣過」,那是會誤導人的訊息。
|
|
53
|
+
if (h.last_ok_at === undefined) {
|
|
54
|
+
const snap = lastSnapshot();
|
|
55
|
+
const t = snap?.["collected_at"];
|
|
56
|
+
if (typeof t === "string" && t) {
|
|
57
|
+
h.last_ok_at = t;
|
|
58
|
+
h.last_ok_at_backfilled = true; // 標明這不是真的成功紀錄
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return h;
|
|
62
|
+
}
|
|
63
|
+
/** Atomic write。health 追蹤本身**絕不可以**弄壞 daemon,所以吞掉所有例外。 */
|
|
64
|
+
export function saveHealth(h) {
|
|
65
|
+
try {
|
|
66
|
+
writeJsonAtomic(healthPath(), h);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// 寫不進去也要讓 daemon 跑完這一輪
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 這台裝置的採樣是不是壞了?回人看得懂的原因,或 null。
|
|
74
|
+
*
|
|
75
|
+
* 這個判斷刻意留在 client:它需要本機才有的上下文(連續失敗次數、距上次成功幾
|
|
76
|
+
* 小時),而同一個判斷只該有一份實作 —— server 收到的 `warning` 就是這裡算好的,
|
|
77
|
+
* 不會再推一次。
|
|
78
|
+
*/
|
|
79
|
+
export function healthWarning(h) {
|
|
80
|
+
const cf = h.consecutive_failures ?? 0;
|
|
81
|
+
if (cf >= WARN_FAILURES) {
|
|
82
|
+
return `連續 ${cf} 次採樣失敗 (${h.last_sample_status}) 自 ${h.first_failure_at}`;
|
|
83
|
+
}
|
|
84
|
+
const ok = parseIso(h.last_ok_at);
|
|
85
|
+
if (ok) {
|
|
86
|
+
const hrs = (Date.now() - ok.getTime()) / 3_600_000;
|
|
87
|
+
if (hrs >= WARN_STALE_HOURS) {
|
|
88
|
+
return `已 ${hrs.toFixed(1)} 小時沒有成功採樣(最後成功 ${h.last_ok_at})`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (h.last_sample_at) {
|
|
92
|
+
return "從未成功採樣過";
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|