@token-dashboard/codex-usage-uploader 0.1.2 → 0.1.3
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 +28 -5
- package/package.json +10 -3
- package/src/cli.js +163 -18
- package/src/collector.js +7 -2
- package/src/constants.js +9 -0
- package/src/install.js +49 -0
- package/src/launchd.js +22 -3
- package/src/local-usage.js +342 -0
- package/src/runtime-config.js +32 -4
- package/src/utils.js +15 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- 后台常驻运行,增量扫描本地 Codex session 数据
|
|
6
6
|
- 首次安装自动补采全量历史
|
|
7
|
-
- macOS launchd
|
|
7
|
+
- macOS launchd 托管,登录自启动可选
|
|
8
8
|
|
|
9
9
|
> 仅支持 macOS,需要 Node.js >= 22.13.0。
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
npx @token-dashboard/codex-usage-uploader init
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json
|
|
19
|
+
安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json`)获取身份信息。如果读取不到邮箱,会在终端交互式提示你手动填写;同时会询问是否在这台 Mac 上启用登录自启动。
|
|
20
20
|
|
|
21
21
|
安装完成后,`codex-usage-uploader` 命令会被写入 `~/bin/`,后续可直接使用。
|
|
22
22
|
|
|
@@ -28,7 +28,7 @@ npx @token-dashboard/codex-usage-uploader init
|
|
|
28
28
|
codex-usage-uploader init
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
首次运行时完成安装与配置;重复运行会升级本地版本并重启后台服务,同时可重新选择是否启用登录自启动。后端地址默认为 `http://101.126.66.51:8086`,如需自定义可通过 `--backend-url` 指定。
|
|
32
32
|
|
|
33
33
|
### 绑定身份
|
|
34
34
|
|
|
@@ -50,6 +50,24 @@ codex-usage-uploader bind --email you@company.com --yes
|
|
|
50
50
|
| `codex-usage-uploader logs` | 查看服务日志(默认最近 100 行) |
|
|
51
51
|
| `codex-usage-uploader logs --lines 500` | 查看更多日志 |
|
|
52
52
|
|
|
53
|
+
### 查看本机 token 用量
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
codex-usage-uploader usage
|
|
57
|
+
codex-usage-uploader usage --period today
|
|
58
|
+
codex-usage-uploader usage --period 7d
|
|
59
|
+
codex-usage-uploader usage --from 2026-04-01 --to 2026-04-17
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`usage` 是一个纯本地只读命令,不依赖后端查询,也不要求先执行 `init`。它会重新扫描当前机器上的 Codex rollout 文件,默认同时包含:
|
|
63
|
+
|
|
64
|
+
- `~/.codex/sessions`
|
|
65
|
+
- 同级的 `~/.codex/archived_sessions`
|
|
66
|
+
|
|
67
|
+
默认输出是一张纯文本表格,列为 `Date`、`Models`、`Input`、`Output`、`Reasoning`、`Cache Read`、`Total Tokens`,最后一行固定为 `Total`。默认 `--period=all` 时,表格覆盖全历史所有有 token 使用的日期。
|
|
68
|
+
|
|
69
|
+
统计口径与现有 uploader/后端保持一致,使用 `token_count` 事件中的 `last_*` token 字段,而不是累计 `total_*` 字段。切天时区使用当前机器时区。
|
|
70
|
+
|
|
53
71
|
### 清除本地状态
|
|
54
72
|
|
|
55
73
|
```bash
|
|
@@ -81,16 +99,20 @@ codex-usage-uploader uninstall
|
|
|
81
99
|
| `--employee-name <name>` | 绑定姓名 |
|
|
82
100
|
| `--employee-id <id>` | 绑定工号 |
|
|
83
101
|
| `--interval <seconds>` | 扫描间隔秒数(默认 30) |
|
|
102
|
+
| `--period <value>` | `usage` 时间范围:`today`、`7d`、`30d`、`all` |
|
|
103
|
+
| `--from <date>` | `usage` 开始日期(`YYYY-MM-DD`) |
|
|
104
|
+
| `--to <date>` | `usage` 结束日期(`YYYY-MM-DD`) |
|
|
84
105
|
| `--yes` | 跳过所有交互确认 |
|
|
85
106
|
| `--lines <n>` | `logs` 命令输出行数(默认 100) |
|
|
86
107
|
| `-h, --help` | 显示帮助 |
|
|
87
108
|
|
|
88
109
|
## 工作原理
|
|
89
110
|
|
|
90
|
-
1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd
|
|
111
|
+
1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd 后台服务,并按交互选择决定是否启用登录自启动。
|
|
91
112
|
2. 后台服务每 30 秒(可通过 `--interval` 调整)增量扫描 `~/.codex/sessions/` 下的 rollout 文件。
|
|
92
113
|
3. 扫描到的 token 用量数据暂存在本地 SQLite 队列中,满足条件后自动封批上传至后端。
|
|
93
|
-
4.
|
|
114
|
+
4. `usage` 命令会按需重扫本地 `sessions + archived_sessions`,直接在终端计算并展示本机 token 用量,不写入本地状态库,也不访问后端。
|
|
115
|
+
5. 所有数据在上报前已做去敏处理,仅包含 session 元信息、turn 上下文摘要和 token 计数。
|
|
94
116
|
|
|
95
117
|
## 本地文件
|
|
96
118
|
|
|
@@ -99,6 +121,7 @@ codex-usage-uploader uninstall
|
|
|
99
121
|
| `~/.codex-usage-uploader/config.json` | 运行配置 |
|
|
100
122
|
| `~/.codex-usage-uploader/state.sqlite` | 本地状态数据库 |
|
|
101
123
|
| `~/.codex-usage-uploader/logs/` | 服务日志 |
|
|
124
|
+
| `~/.codex-usage-uploader/launchd/` | launchd service plist |
|
|
102
125
|
|
|
103
126
|
## License
|
|
104
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@token-dashboard/codex-usage-uploader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Codex 用量上报 CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"init": "node bin/codex-usage-uploader.js init --backend-url http://localhost:8086",
|
|
16
|
+
"start": "node bin/codex-usage-uploader.js start",
|
|
17
|
+
"stop": "node bin/codex-usage-uploader.js stop",
|
|
18
|
+
"restart": "node bin/codex-usage-uploader.js restart",
|
|
19
|
+
"status": "node bin/codex-usage-uploader.js status",
|
|
20
|
+
"usage": "node bin/codex-usage-uploader.js usage",
|
|
21
|
+
"logs": "node bin/codex-usage-uploader.js logs",
|
|
22
|
+
"clear": "node bin/codex-usage-uploader.js clear",
|
|
23
|
+
"uninstall": "node bin/codex-usage-uploader.js uninstall"
|
|
17
24
|
},
|
|
18
25
|
"engines": {
|
|
19
26
|
"node": ">=22.13.0"
|
package/src/cli.js
CHANGED
|
@@ -3,9 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { CodexUsageUploader } from './collector.js';
|
|
4
4
|
import { identityIsBound, promptConfirm } from './auth.js';
|
|
5
5
|
import { CLI_NAME, DEFAULT_BACKEND_URL, DEFAULT_CONFIG_FILE, PRODUCT_NAME } from './constants.js';
|
|
6
|
-
import { findPackageRoot, installCurrentPackage } from './install.js';
|
|
6
|
+
import { ensurePathInShellConfigs, findPackageRoot, installCurrentPackage, removePathFromShellConfigs } from './install.js';
|
|
7
7
|
import { LaunchdServiceManager } from './launchd.js';
|
|
8
|
-
import {
|
|
8
|
+
import { collectLocalUsage, getLocalTimeZone } from './local-usage.js';
|
|
9
|
+
import { formatStatusOutput, mergeRuntimeConfig, saveRuntimeConfig } from './runtime-config.js';
|
|
9
10
|
import { StateDb } from './state-db.js';
|
|
10
11
|
|
|
11
12
|
const HELP_TEXT = `${PRODUCT_NAME}
|
|
@@ -18,6 +19,7 @@ Usage:
|
|
|
18
19
|
${CLI_NAME} stop
|
|
19
20
|
${CLI_NAME} restart
|
|
20
21
|
${CLI_NAME} status
|
|
22
|
+
${CLI_NAME} usage [--period <today|7d|30d|all>] [--from <date>] [--to <date>]
|
|
21
23
|
${CLI_NAME} logs [--lines 100]
|
|
22
24
|
${CLI_NAME} uninstall
|
|
23
25
|
|
|
@@ -33,6 +35,9 @@ Common options:
|
|
|
33
35
|
--employee-name <name> Bind employee name
|
|
34
36
|
--device-id <id> Override device ID
|
|
35
37
|
--hostname <name> Override hostname
|
|
38
|
+
--period <value> Usage range: today, 7d, 30d, or all
|
|
39
|
+
--from <date> Usage range start date (YYYY-MM-DD)
|
|
40
|
+
--to <date> Usage range end date (YYYY-MM-DD)
|
|
36
41
|
--yes Skip interactive prompts
|
|
37
42
|
--package-spec <spec> npm install spec used by init
|
|
38
43
|
--lines <n> Number of log lines for service logs
|
|
@@ -50,6 +55,9 @@ class CliOperationalError extends Error {
|
|
|
50
55
|
export const cliDeps = {
|
|
51
56
|
findPackageRoot,
|
|
52
57
|
installCurrentPackage,
|
|
58
|
+
ensurePathInShellConfigs,
|
|
59
|
+
removePathFromShellConfigs,
|
|
60
|
+
promptConfirm,
|
|
53
61
|
createUploader(runtime) {
|
|
54
62
|
return new CodexUsageUploader({
|
|
55
63
|
sessionsDir: runtime.sessionsDir,
|
|
@@ -57,6 +65,7 @@ export const cliDeps = {
|
|
|
57
65
|
backendUrl: runtime.backendUrl,
|
|
58
66
|
intervalSeconds: runtime.intervalSeconds,
|
|
59
67
|
codexAuthPath: runtime.codexAuthPath,
|
|
68
|
+
persistentCollectorIdPath: runtime.persistentCollectorIdPath,
|
|
60
69
|
});
|
|
61
70
|
},
|
|
62
71
|
createServiceManager(runtime) {
|
|
@@ -102,6 +111,9 @@ export function parseCliArgs(argv) {
|
|
|
102
111
|
'--employee-name',
|
|
103
112
|
'--device-id',
|
|
104
113
|
'--hostname',
|
|
114
|
+
'--period',
|
|
115
|
+
'--from',
|
|
116
|
+
'--to',
|
|
105
117
|
'--package-spec',
|
|
106
118
|
'--lines',
|
|
107
119
|
]);
|
|
@@ -158,6 +170,15 @@ function assignOption(options, key, value) {
|
|
|
158
170
|
case '--hostname':
|
|
159
171
|
options.hostname = value;
|
|
160
172
|
break;
|
|
173
|
+
case '--period':
|
|
174
|
+
options.period = value;
|
|
175
|
+
break;
|
|
176
|
+
case '--from':
|
|
177
|
+
options.from = value;
|
|
178
|
+
break;
|
|
179
|
+
case '--to':
|
|
180
|
+
options.to = value;
|
|
181
|
+
break;
|
|
161
182
|
case '--package-spec':
|
|
162
183
|
options.packageSpec = value;
|
|
163
184
|
break;
|
|
@@ -285,17 +306,21 @@ function wrapCatchUpFailure(error) {
|
|
|
285
306
|
);
|
|
286
307
|
}
|
|
287
308
|
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
console.log(
|
|
294
|
-
`Note: ${linkDir} is not in your PATH. To use \`${CLI_NAME}\` directly, run:`,
|
|
309
|
+
function wrapUsageFailure(error) {
|
|
310
|
+
return new CliOperationalError(
|
|
311
|
+
`Usage query failed: ${
|
|
312
|
+
error instanceof Error ? error.message : String(error)
|
|
313
|
+
}`,
|
|
295
314
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function resolveAutoStartOnLogin(runtime, options) {
|
|
318
|
+
if (options.yes) {
|
|
319
|
+
return runtime.autoStartOnLogin;
|
|
320
|
+
}
|
|
321
|
+
return cliDeps.promptConfirm(
|
|
322
|
+
'Start automatically when you log in on this Mac?',
|
|
323
|
+
runtime.autoStartOnLogin,
|
|
299
324
|
);
|
|
300
325
|
}
|
|
301
326
|
|
|
@@ -317,7 +342,7 @@ async function runInit(options) {
|
|
|
317
342
|
nodePath: process.execPath,
|
|
318
343
|
});
|
|
319
344
|
|
|
320
|
-
|
|
345
|
+
let manager = cliDeps.createServiceManager(runtime);
|
|
321
346
|
manager.stop();
|
|
322
347
|
|
|
323
348
|
const uploader = cliDeps.createUploader(runtime);
|
|
@@ -339,20 +364,126 @@ async function runInit(options) {
|
|
|
339
364
|
throw wrapCatchUpFailure(error);
|
|
340
365
|
}
|
|
341
366
|
|
|
367
|
+
runtime.autoStartOnLogin = await resolveAutoStartOnLogin(runtime, options);
|
|
368
|
+
runtime = saveRuntimeConfig(runtime);
|
|
369
|
+
manager = cliDeps.createServiceManager(runtime);
|
|
342
370
|
manager.start();
|
|
343
371
|
console.log(`${PRODUCT_NAME} initialized and started.`);
|
|
372
|
+
console.log(
|
|
373
|
+
runtime.autoStartOnLogin
|
|
374
|
+
? 'Login auto-start is enabled.'
|
|
375
|
+
: 'Login auto-start is disabled for future logins.',
|
|
376
|
+
);
|
|
344
377
|
if (scanResult.pendingBatches > 0) {
|
|
345
378
|
console.log(
|
|
346
379
|
`Background service is uploading ${scanResult.pendingBatches} batch(es) with ${scanResult.pendingEvents} event(s).`,
|
|
347
380
|
);
|
|
348
381
|
}
|
|
349
382
|
console.log(`Use \`${CLI_NAME} status\` to check the local service.`);
|
|
350
|
-
|
|
383
|
+
const linkDir = path.dirname(runtime.homeBinLink);
|
|
384
|
+
const pathDirs = (process.env.PATH || '').split(path.delimiter);
|
|
385
|
+
if (!pathDirs.includes(linkDir)) {
|
|
386
|
+
cliDeps.ensurePathInShellConfigs(linkDir);
|
|
387
|
+
console.log(`Open a new terminal to use \`${CLI_NAME}\` directly.`);
|
|
388
|
+
}
|
|
351
389
|
} finally {
|
|
352
390
|
uploader.close();
|
|
353
391
|
}
|
|
354
392
|
}
|
|
355
393
|
|
|
394
|
+
function formatCount(value) {
|
|
395
|
+
return Number(value ?? 0).toLocaleString('en-US');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderTable(headers, rows, alignments = []) {
|
|
399
|
+
const widths = headers.map((header, index) =>
|
|
400
|
+
Math.max(
|
|
401
|
+
header.length,
|
|
402
|
+
...rows.map((row) => String(row[index] ?? '').length),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
const formatCell = (cell, index) => {
|
|
406
|
+
const text = String(cell ?? '');
|
|
407
|
+
return alignments[index] === 'right'
|
|
408
|
+
? text.padStart(widths[index])
|
|
409
|
+
: text.padEnd(widths[index]);
|
|
410
|
+
};
|
|
411
|
+
const formatRow = (row) =>
|
|
412
|
+
row
|
|
413
|
+
.map((cell, index) => formatCell(cell, index))
|
|
414
|
+
.join(' ')
|
|
415
|
+
.trimEnd();
|
|
416
|
+
return [
|
|
417
|
+
formatRow(headers),
|
|
418
|
+
...rows.map((row) => formatRow(row)),
|
|
419
|
+
].join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatUsageDate(dateString) {
|
|
423
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
424
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
425
|
+
timeZone: 'UTC',
|
|
426
|
+
month: 'short',
|
|
427
|
+
day: 'numeric',
|
|
428
|
+
year: 'numeric',
|
|
429
|
+
}).format(new Date(Date.UTC(year, month - 1, day)));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function printUsageReport(report) {
|
|
433
|
+
if (report.tokenEventCount === 0) {
|
|
434
|
+
console.log('No local Codex usage found for selected range.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const rows = report.days.map((row) => [
|
|
439
|
+
formatUsageDate(row.date),
|
|
440
|
+
row.models.join(', '),
|
|
441
|
+
formatCount(row.inputTokens),
|
|
442
|
+
formatCount(row.outputTokens),
|
|
443
|
+
formatCount(row.reasoningOutputTokens),
|
|
444
|
+
formatCount(row.cachedInputTokens),
|
|
445
|
+
formatCount(row.totalTokens),
|
|
446
|
+
]);
|
|
447
|
+
rows.push([
|
|
448
|
+
'Total',
|
|
449
|
+
'',
|
|
450
|
+
formatCount(report.summary.inputTokens),
|
|
451
|
+
formatCount(report.summary.outputTokens),
|
|
452
|
+
formatCount(report.summary.reasoningOutputTokens),
|
|
453
|
+
formatCount(report.summary.cachedInputTokens),
|
|
454
|
+
formatCount(report.summary.totalTokens),
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
console.log(
|
|
458
|
+
renderTable(
|
|
459
|
+
['Date', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens'],
|
|
460
|
+
rows,
|
|
461
|
+
['left', 'left', 'right', 'right', 'right', 'right', 'right'],
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function runUsage(options) {
|
|
467
|
+
const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
|
|
468
|
+
|
|
469
|
+
console.log('Scanning local Codex usage...');
|
|
470
|
+
|
|
471
|
+
let report;
|
|
472
|
+
try {
|
|
473
|
+
report = await collectLocalUsage({
|
|
474
|
+
sessionsDir: runtime.sessionsDir,
|
|
475
|
+
period: options.period,
|
|
476
|
+
from: options.from,
|
|
477
|
+
to: options.to,
|
|
478
|
+
timeZone: getLocalTimeZone(),
|
|
479
|
+
});
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw wrapUsageFailure(error);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
printUsageReport(report);
|
|
485
|
+
}
|
|
486
|
+
|
|
356
487
|
async function runBind(options) {
|
|
357
488
|
const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
|
|
358
489
|
const uploader = cliDeps.createUploader(runtime);
|
|
@@ -386,6 +517,7 @@ async function runInternalWorker(options) {
|
|
|
386
517
|
function loadQueueStats(stateDbPath) {
|
|
387
518
|
if (!fs.existsSync(stateDbPath)) {
|
|
388
519
|
return {
|
|
520
|
+
collectorId: null,
|
|
389
521
|
bufferingBatchCount: 0,
|
|
390
522
|
pendingBatchCount: 0,
|
|
391
523
|
retryingBatchCount: 0,
|
|
@@ -397,7 +529,11 @@ function loadQueueStats(stateDbPath) {
|
|
|
397
529
|
}
|
|
398
530
|
const stateDb = new StateDb(stateDbPath);
|
|
399
531
|
try {
|
|
400
|
-
|
|
532
|
+
const identity = stateDb.getIdentity();
|
|
533
|
+
return {
|
|
534
|
+
collectorId: identity.collectorId ?? null,
|
|
535
|
+
...stateDb.getQueueStats(),
|
|
536
|
+
};
|
|
401
537
|
} finally {
|
|
402
538
|
stateDb.close();
|
|
403
539
|
}
|
|
@@ -412,10 +548,13 @@ function printStatus(runtime, status) {
|
|
|
412
548
|
['Config exists', payload.configExists ? 'yes' : 'no'],
|
|
413
549
|
['Loaded', payload.loaded ? 'yes' : 'no'],
|
|
414
550
|
['Running', payload.running ? 'yes' : 'no'],
|
|
551
|
+
['Auto start on login', payload.autoStartOnLogin ? 'yes' : 'no'],
|
|
415
552
|
['PID', payload.pid ?? '-'],
|
|
416
553
|
['State', payload.state ?? '-'],
|
|
417
554
|
['Last exit code', payload.lastExitCode ?? '-'],
|
|
418
|
-
['
|
|
555
|
+
['Collector ID', payload.collectorId ?? '-'],
|
|
556
|
+
['Upload URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/upload` : '-'],
|
|
557
|
+
['Register URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/collectors/register` : '-'],
|
|
419
558
|
['Interval', `${payload.intervalSeconds}s`],
|
|
420
559
|
['Buffering batches', payload.bufferingBatchCount],
|
|
421
560
|
['Pending batches', payload.pendingBatchCount],
|
|
@@ -433,7 +572,8 @@ function printStatus(runtime, status) {
|
|
|
433
572
|
['State DB', payload.stateDbPath],
|
|
434
573
|
['Stdout log', payload.stdoutLogPath],
|
|
435
574
|
['Stderr log', payload.stderrLogPath],
|
|
436
|
-
['
|
|
575
|
+
['Service plist', payload.plistPath],
|
|
576
|
+
['LaunchAgent plist', payload.launchAgentPath],
|
|
437
577
|
['Label', payload.label],
|
|
438
578
|
];
|
|
439
579
|
for (const [label, value] of lines) {
|
|
@@ -462,7 +602,7 @@ async function runClear(options) {
|
|
|
462
602
|
}
|
|
463
603
|
|
|
464
604
|
if (!options.yes) {
|
|
465
|
-
const confirmed = await promptConfirm(
|
|
605
|
+
const confirmed = await cliDeps.promptConfirm(
|
|
466
606
|
'Clear local backfill state and queued uploads?',
|
|
467
607
|
false,
|
|
468
608
|
);
|
|
@@ -533,6 +673,7 @@ function runLifecycleCommand(action, options) {
|
|
|
533
673
|
}
|
|
534
674
|
case 'uninstall':
|
|
535
675
|
manager.uninstall();
|
|
676
|
+
cliDeps.removePathFromShellConfigs();
|
|
536
677
|
fs.rmSync(runtime.installRoot, { recursive: true, force: true });
|
|
537
678
|
console.log(`${PRODUCT_NAME} uninstalled from ${runtime.installRoot}`);
|
|
538
679
|
return;
|
|
@@ -557,6 +698,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
557
698
|
'stop',
|
|
558
699
|
'restart',
|
|
559
700
|
'status',
|
|
701
|
+
'usage',
|
|
560
702
|
'logs',
|
|
561
703
|
'uninstall',
|
|
562
704
|
'run',
|
|
@@ -577,6 +719,9 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
577
719
|
case 'clear':
|
|
578
720
|
await runClear(options);
|
|
579
721
|
return 0;
|
|
722
|
+
case 'usage':
|
|
723
|
+
await runUsage(options);
|
|
724
|
+
return 0;
|
|
580
725
|
case 'run':
|
|
581
726
|
await runInternalWorker(options);
|
|
582
727
|
return 0;
|
package/src/collector.js
CHANGED
|
@@ -5,11 +5,12 @@ import { createInterface } from 'node:readline';
|
|
|
5
5
|
import { loadCodexAuthIdentity, identityIsBound, promptValue } from './auth.js';
|
|
6
6
|
import { RolloutParser } from './parser.js';
|
|
7
7
|
import { StateDb } from './state-db.js';
|
|
8
|
-
import { nowTs, sleep, stableStringify } from './utils.js';
|
|
8
|
+
import { nowTs, sleep, stableStringify, readPersistentCollectorId, writePersistentCollectorId } from './utils.js';
|
|
9
9
|
import {
|
|
10
10
|
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
11
11
|
MAX_BATCH_BYTES,
|
|
12
12
|
MAX_EVENTS_PER_BATCH,
|
|
13
|
+
PERSISTENT_COLLECTOR_ID_PATH,
|
|
13
14
|
REGISTER_REFRESH_SECONDS,
|
|
14
15
|
SCAN_CHUNK_MAX_BYTES,
|
|
15
16
|
SCAN_CHUNK_MAX_EVENTS,
|
|
@@ -23,6 +24,7 @@ export class CodexUsageUploader {
|
|
|
23
24
|
backendUrl,
|
|
24
25
|
intervalSeconds,
|
|
25
26
|
codexAuthPath,
|
|
27
|
+
persistentCollectorIdPath = PERSISTENT_COLLECTOR_ID_PATH,
|
|
26
28
|
scanChunkMaxEvents = SCAN_CHUNK_MAX_EVENTS,
|
|
27
29
|
scanChunkMaxBytes = SCAN_CHUNK_MAX_BYTES,
|
|
28
30
|
}) {
|
|
@@ -31,6 +33,7 @@ export class CodexUsageUploader {
|
|
|
31
33
|
this.backendUrl = backendUrl?.replace(/\/+$/, '') || null;
|
|
32
34
|
this.intervalSeconds = intervalSeconds;
|
|
33
35
|
this.codexAuthPath = codexAuthPath;
|
|
36
|
+
this.persistentCollectorIdPath = persistentCollectorIdPath;
|
|
34
37
|
this.scanChunkMaxEvents = scanChunkMaxEvents;
|
|
35
38
|
this.scanChunkMaxBytes = scanChunkMaxBytes;
|
|
36
39
|
this.identity = this.ensureIdentity();
|
|
@@ -43,10 +46,12 @@ export class CodexUsageUploader {
|
|
|
43
46
|
ensureIdentity() {
|
|
44
47
|
const identity = this.stateDb.getIdentity();
|
|
45
48
|
if (!identity.collectorId) {
|
|
46
|
-
|
|
49
|
+
const persisted = readPersistentCollectorId(this.persistentCollectorIdPath);
|
|
50
|
+
identity.collectorId = persisted ?? `${Date.now().toString(16)}${Math.random()
|
|
47
51
|
.toString(16)
|
|
48
52
|
.slice(2, 14)}`;
|
|
49
53
|
}
|
|
54
|
+
writePersistentCollectorId(this.persistentCollectorIdPath, identity.collectorId);
|
|
50
55
|
if (!identity.deviceId) {
|
|
51
56
|
identity.deviceId = `${os.hostname()}-${Math.random()
|
|
52
57
|
.toString(16)
|
package/src/constants.js
CHANGED
|
@@ -20,11 +20,20 @@ export const DEFAULT_LOCAL_BIN_PATH = path.join(DEFAULT_LOCAL_BIN_DIR, CLI_NAME)
|
|
|
20
20
|
export const DEFAULT_HOME_BIN_LINK = path.join(os.homedir(), 'bin', CLI_NAME);
|
|
21
21
|
export const DEFAULT_LAUNCHD_LABEL = 'com.token-dashboard.codex-usage-uploader';
|
|
22
22
|
export const DEFAULT_PLIST_PATH = path.join(
|
|
23
|
+
DEFAULT_INSTALL_ROOT,
|
|
24
|
+
'launchd',
|
|
25
|
+
`${DEFAULT_LAUNCHD_LABEL}.plist`,
|
|
26
|
+
);
|
|
27
|
+
export const DEFAULT_LAUNCH_AGENT_PATH = path.join(
|
|
23
28
|
os.homedir(),
|
|
24
29
|
'Library',
|
|
25
30
|
'LaunchAgents',
|
|
26
31
|
`${DEFAULT_LAUNCHD_LABEL}.plist`,
|
|
27
32
|
);
|
|
33
|
+
export const PERSISTENT_COLLECTOR_ID_PATH = path.join(
|
|
34
|
+
os.homedir(),
|
|
35
|
+
'.codex-usage-uploader-collector-id',
|
|
36
|
+
);
|
|
28
37
|
export const DEFAULT_BACKEND_URL = 'http://101.126.66.51:8086';
|
|
29
38
|
export const SCAN_CHUNK_MAX_EVENTS = 50;
|
|
30
39
|
export const SCAN_CHUNK_MAX_BYTES = 256 * 1024;
|
package/src/install.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { CLI_NAME } from './constants.js';
|
|
6
7
|
import { saveRuntimeConfig } from './runtime-config.js';
|
|
7
8
|
|
|
9
|
+
const SHELL_CONFIG_MARKER = '# Added by codex-usage-uploader';
|
|
10
|
+
const SHELL_CONFIG_FILES = [
|
|
11
|
+
path.join(os.homedir(), '.zshrc'),
|
|
12
|
+
path.join(os.homedir(), '.bash_profile'),
|
|
13
|
+
];
|
|
14
|
+
|
|
8
15
|
export function findPackageRoot(startUrl = import.meta.url) {
|
|
9
16
|
let current = path.dirname(fileURLToPath(startUrl));
|
|
10
17
|
while (true) {
|
|
@@ -99,3 +106,45 @@ export function ensureHomeBinLink(runtime) {
|
|
|
99
106
|
}
|
|
100
107
|
fs.symlinkSync(runtime.localBinPath, runtime.homeBinLink);
|
|
101
108
|
}
|
|
109
|
+
|
|
110
|
+
export function ensurePathInShellConfigs(binDir) {
|
|
111
|
+
const exportLine = `export PATH="${binDir}:$PATH"`;
|
|
112
|
+
for (const configFile of SHELL_CONFIG_FILES) {
|
|
113
|
+
try {
|
|
114
|
+
if (!fs.existsSync(configFile)) continue;
|
|
115
|
+
const content = fs.readFileSync(configFile, 'utf8');
|
|
116
|
+
if (content.includes(SHELL_CONFIG_MARKER)) continue;
|
|
117
|
+
const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
118
|
+
fs.appendFileSync(configFile, `${prefix}\n${SHELL_CONFIG_MARKER}\n${exportLine}\n`);
|
|
119
|
+
} catch {
|
|
120
|
+
// best-effort, ignore errors
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function removePathFromShellConfigs() {
|
|
126
|
+
for (const configFile of SHELL_CONFIG_FILES) {
|
|
127
|
+
try {
|
|
128
|
+
if (!fs.existsSync(configFile)) continue;
|
|
129
|
+
const content = fs.readFileSync(configFile, 'utf8');
|
|
130
|
+
if (!content.includes(SHELL_CONFIG_MARKER)) continue;
|
|
131
|
+
const lines = content.split('\n');
|
|
132
|
+
const filtered = [];
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
if (lines[i] === SHELL_CONFIG_MARKER) {
|
|
135
|
+
if (i + 1 < lines.length && lines[i + 1].startsWith('export PATH=')) {
|
|
136
|
+
i += 1;
|
|
137
|
+
}
|
|
138
|
+
if (filtered.length > 0 && filtered[filtered.length - 1] === '') {
|
|
139
|
+
filtered.pop();
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
filtered.push(lines[i]);
|
|
144
|
+
}
|
|
145
|
+
fs.writeFileSync(configFile, filtered.join('\n'));
|
|
146
|
+
} catch {
|
|
147
|
+
// best-effort, ignore errors
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/launchd.js
CHANGED
|
@@ -96,21 +96,37 @@ export class LaunchdServiceManager {
|
|
|
96
96
|
if (!this.runtime.entryFile || !fs.existsSync(this.runtime.entryFile)) {
|
|
97
97
|
throw new Error(`installed runtime entry not found: ${this.runtime.entryFile || '(empty)'}`);
|
|
98
98
|
}
|
|
99
|
+
const plist = buildLaunchdPlist(this.runtime);
|
|
99
100
|
fs.mkdirSync(path.dirname(this.runtime.plistPath), { recursive: true });
|
|
100
101
|
fs.mkdirSync(path.dirname(this.runtime.stdoutLogPath), { recursive: true });
|
|
101
|
-
fs.writeFileSync(this.runtime.plistPath,
|
|
102
|
+
fs.writeFileSync(this.runtime.plistPath, plist);
|
|
103
|
+
this.syncLaunchAgent(plist);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
syncLaunchAgent(plist) {
|
|
107
|
+
const launchAgentPath = this.runtime.launchAgentPath;
|
|
108
|
+
if (!launchAgentPath) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (this.runtime.autoStartOnLogin) {
|
|
112
|
+
fs.mkdirSync(path.dirname(launchAgentPath), { recursive: true });
|
|
113
|
+
fs.writeFileSync(launchAgentPath, plist);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (fs.existsSync(launchAgentPath)) {
|
|
117
|
+
fs.rmSync(launchAgentPath, { force: true });
|
|
118
|
+
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
start() {
|
|
105
122
|
this.ensurePlist();
|
|
106
123
|
this.stop();
|
|
107
124
|
this.run(['launchctl', 'bootstrap', this.domainTarget(), this.runtime.plistPath]);
|
|
108
|
-
this.run(['launchctl', 'kickstart', '-k', this.serviceTarget()], { check: false });
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
stop() {
|
|
112
128
|
this.ensureMacos();
|
|
113
|
-
this.run(['launchctl', 'bootout', this.
|
|
129
|
+
this.run(['launchctl', 'bootout', this.serviceTarget()], { check: false });
|
|
114
130
|
}
|
|
115
131
|
|
|
116
132
|
restart() {
|
|
@@ -137,6 +153,9 @@ export class LaunchdServiceManager {
|
|
|
137
153
|
if (fs.existsSync(this.runtime.plistPath)) {
|
|
138
154
|
fs.unlinkSync(this.runtime.plistPath);
|
|
139
155
|
}
|
|
156
|
+
if (this.runtime.launchAgentPath && fs.existsSync(this.runtime.launchAgentPath)) {
|
|
157
|
+
fs.unlinkSync(this.runtime.launchAgentPath);
|
|
158
|
+
}
|
|
140
159
|
if (fs.existsSync(this.runtime.homeBinLink) && fs.lstatSync(this.runtime.homeBinLink).isSymbolicLink()) {
|
|
141
160
|
try {
|
|
142
161
|
const target = fs.realpathSync.native(this.runtime.homeBinLink);
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import {
|
|
5
|
+
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
6
|
+
SESSIONS_SOURCE_ROOT,
|
|
7
|
+
} from './constants.js';
|
|
8
|
+
import { RolloutParser } from './parser.js';
|
|
9
|
+
|
|
10
|
+
const LOCAL_USAGE_COLLECTOR = Object.freeze({ collectorId: 'local-usage' });
|
|
11
|
+
const VALID_PERIODS = new Set(['today', '7d', '30d', 'all']);
|
|
12
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
|
|
14
|
+
export function getLocalTimeZone() {
|
|
15
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveUsageQuery({
|
|
19
|
+
period,
|
|
20
|
+
from,
|
|
21
|
+
to,
|
|
22
|
+
now = Date.now(),
|
|
23
|
+
timeZone = getLocalTimeZone(),
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (period && (from || to)) {
|
|
26
|
+
throw new Error('--period cannot be combined with --from or --to');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (period && !VALID_PERIODS.has(period)) {
|
|
30
|
+
throw new Error(`--period must be one of: today, 7d, 30d, all`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const today = formatDateInTimeZone(now, timeZone);
|
|
34
|
+
|
|
35
|
+
if (period === 'today') {
|
|
36
|
+
return {
|
|
37
|
+
mode: 'today',
|
|
38
|
+
from: today,
|
|
39
|
+
to: today,
|
|
40
|
+
trendFrom: today,
|
|
41
|
+
trendTo: today,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (period === '7d') {
|
|
46
|
+
return {
|
|
47
|
+
mode: '7d',
|
|
48
|
+
from: addDays(today, -6),
|
|
49
|
+
to: today,
|
|
50
|
+
trendFrom: addDays(today, -6),
|
|
51
|
+
trendTo: today,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (period === '30d') {
|
|
56
|
+
return {
|
|
57
|
+
mode: '30d',
|
|
58
|
+
from: addDays(today, -29),
|
|
59
|
+
to: today,
|
|
60
|
+
trendFrom: addDays(today, -29),
|
|
61
|
+
trendTo: today,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (period === 'all' || (!period && !from && !to)) {
|
|
66
|
+
return {
|
|
67
|
+
mode: 'all',
|
|
68
|
+
from: null,
|
|
69
|
+
to: null,
|
|
70
|
+
trendFrom: addDays(today, -29),
|
|
71
|
+
trendTo: today,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const normalizedFrom = from == null ? null : normalizeDateString(from, '--from');
|
|
76
|
+
const normalizedTo = to == null ? null : normalizeDateString(to, '--to');
|
|
77
|
+
|
|
78
|
+
if (normalizedFrom && normalizedTo && normalizedFrom > normalizedTo) {
|
|
79
|
+
throw new Error('--from cannot be later than --to');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
mode: 'custom',
|
|
84
|
+
from: normalizedFrom,
|
|
85
|
+
to: normalizedTo,
|
|
86
|
+
trendFrom: normalizedFrom,
|
|
87
|
+
trendTo: normalizedTo,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function collectLocalUsage({
|
|
92
|
+
sessionsDir,
|
|
93
|
+
period,
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
now = Date.now(),
|
|
97
|
+
timeZone = getLocalTimeZone(),
|
|
98
|
+
includeArchived = true,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const range = resolveUsageQuery({ period, from, to, now, timeZone });
|
|
101
|
+
const archivedSessionsDir = path.join(
|
|
102
|
+
path.dirname(sessionsDir),
|
|
103
|
+
ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const roots = [
|
|
107
|
+
{ sourceRoot: SESSIONS_SOURCE_ROOT, dir: sessionsDir },
|
|
108
|
+
];
|
|
109
|
+
if (includeArchived) {
|
|
110
|
+
roots.push({
|
|
111
|
+
sourceRoot: ARCHIVED_SESSIONS_SOURCE_ROOT,
|
|
112
|
+
dir: archivedSessionsDir,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const entries = roots.flatMap((root) =>
|
|
117
|
+
iterRolloutFiles(root.dir).map((filePath) => ({
|
|
118
|
+
...root,
|
|
119
|
+
filePath,
|
|
120
|
+
relpath: path.relative(root.dir, filePath).split(path.sep).join('/'),
|
|
121
|
+
})),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
entries.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
125
|
+
|
|
126
|
+
const summary = emptyUsageRow();
|
|
127
|
+
const dayMap = new Map();
|
|
128
|
+
const trendMap = new Map();
|
|
129
|
+
const modelMap = new Map();
|
|
130
|
+
let tokenEventCount = 0;
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const parser = new RolloutParser(
|
|
134
|
+
LOCAL_USAGE_COLLECTOR,
|
|
135
|
+
`${entry.sourceRoot}/${entry.relpath}`,
|
|
136
|
+
);
|
|
137
|
+
const input = fs.createReadStream(entry.filePath, { encoding: 'utf8' });
|
|
138
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
139
|
+
let lineNo = 0;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
for await (const line of rl) {
|
|
143
|
+
lineNo += 1;
|
|
144
|
+
if (!line) continue;
|
|
145
|
+
const parsed = parser.processLine(lineNo, line);
|
|
146
|
+
for (const event of parsed.events) {
|
|
147
|
+
const eventDate = formatDateInTimeZone(event.timestamp, timeZone);
|
|
148
|
+
if (!isWithinDateRange(eventDate, range.from, range.to)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const usage = usageFromEvent(event);
|
|
153
|
+
tokenEventCount += 1;
|
|
154
|
+
accumulateUsage(summary, usage);
|
|
155
|
+
|
|
156
|
+
const currentDay = dayMap.get(eventDate) ?? {
|
|
157
|
+
date: eventDate,
|
|
158
|
+
eventCount: 0,
|
|
159
|
+
models: new Set(),
|
|
160
|
+
...emptyUsageRow(),
|
|
161
|
+
};
|
|
162
|
+
currentDay.eventCount += 1;
|
|
163
|
+
currentDay.models.add(usage.model);
|
|
164
|
+
accumulateUsage(currentDay, usage);
|
|
165
|
+
dayMap.set(eventDate, currentDay);
|
|
166
|
+
|
|
167
|
+
const currentModel = modelMap.get(usage.model) ?? {
|
|
168
|
+
model: usage.model,
|
|
169
|
+
eventCount: 0,
|
|
170
|
+
...emptyUsageRow(),
|
|
171
|
+
};
|
|
172
|
+
currentModel.eventCount += 1;
|
|
173
|
+
accumulateUsage(currentModel, usage);
|
|
174
|
+
modelMap.set(usage.model, currentModel);
|
|
175
|
+
|
|
176
|
+
if (isWithinDateRange(eventDate, range.trendFrom, range.trendTo)) {
|
|
177
|
+
const currentDate = trendMap.get(eventDate) ?? {
|
|
178
|
+
date: eventDate,
|
|
179
|
+
eventCount: 0,
|
|
180
|
+
...emptyUsageRow(),
|
|
181
|
+
};
|
|
182
|
+
currentDate.eventCount += 1;
|
|
183
|
+
accumulateUsage(currentDate, usage);
|
|
184
|
+
trendMap.set(eventDate, currentDate);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
rl.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const days = [...dayMap.values()]
|
|
194
|
+
.sort((left, right) => left.date.localeCompare(right.date))
|
|
195
|
+
.map((row) => ({
|
|
196
|
+
...row,
|
|
197
|
+
models: [...row.models].sort((left, right) => left.localeCompare(right)),
|
|
198
|
+
}));
|
|
199
|
+
const trend = materializeTrendRows(range, trendMap);
|
|
200
|
+
const byModel = [...modelMap.values()].sort((left, right) => {
|
|
201
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
202
|
+
return right.totalTokens - left.totalTokens;
|
|
203
|
+
}
|
|
204
|
+
return left.model.localeCompare(right.model);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
scope: 'local-machine',
|
|
209
|
+
timezone: timeZone,
|
|
210
|
+
range,
|
|
211
|
+
sources: {
|
|
212
|
+
sessionsDir,
|
|
213
|
+
archivedSessionsDir,
|
|
214
|
+
includeArchived,
|
|
215
|
+
},
|
|
216
|
+
filesScanned: entries.length,
|
|
217
|
+
tokenEventCount,
|
|
218
|
+
summary,
|
|
219
|
+
days,
|
|
220
|
+
trend,
|
|
221
|
+
byModel,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function iterRolloutFiles(rootDir) {
|
|
226
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
227
|
+
const files = [];
|
|
228
|
+
const walk = (dir) => {
|
|
229
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
230
|
+
const fullPath = path.join(dir, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
walk(fullPath);
|
|
233
|
+
} else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
|
|
234
|
+
files.push(fullPath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
walk(rootDir);
|
|
239
|
+
return files;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function emptyUsageRow() {
|
|
243
|
+
return {
|
|
244
|
+
inputTokens: 0,
|
|
245
|
+
cachedInputTokens: 0,
|
|
246
|
+
outputTokens: 0,
|
|
247
|
+
reasoningOutputTokens: 0,
|
|
248
|
+
totalTokens: 0,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function materializeTrendRows(range, trendMap) {
|
|
253
|
+
const rows = [...trendMap.values()].sort((left, right) =>
|
|
254
|
+
left.date.localeCompare(right.date),
|
|
255
|
+
);
|
|
256
|
+
if (rows.length === 0) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
if (!range.trendFrom || !range.trendTo || range.trendFrom > range.trendTo) {
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
const materialized = [];
|
|
263
|
+
for (let date = range.trendFrom; date <= range.trendTo; date = addDays(date, 1)) {
|
|
264
|
+
materialized.push(
|
|
265
|
+
trendMap.get(date) ?? {
|
|
266
|
+
date,
|
|
267
|
+
eventCount: 0,
|
|
268
|
+
...emptyUsageRow(),
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return materialized;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function usageFromEvent(event) {
|
|
276
|
+
return {
|
|
277
|
+
model: event.model ?? '(unknown)',
|
|
278
|
+
inputTokens: numberOrZero(event.lastInputTokens),
|
|
279
|
+
cachedInputTokens: numberOrZero(event.lastCachedInputTokens),
|
|
280
|
+
outputTokens: numberOrZero(event.lastOutputTokens),
|
|
281
|
+
reasoningOutputTokens: numberOrZero(event.lastReasoningOutputTokens),
|
|
282
|
+
totalTokens: numberOrZero(event.lastTotalTokens),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function accumulateUsage(target, usage) {
|
|
287
|
+
target.inputTokens += usage.inputTokens;
|
|
288
|
+
target.cachedInputTokens += usage.cachedInputTokens;
|
|
289
|
+
target.outputTokens += usage.outputTokens;
|
|
290
|
+
target.reasoningOutputTokens += usage.reasoningOutputTokens;
|
|
291
|
+
target.totalTokens += usage.totalTokens;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function numberOrZero(value) {
|
|
295
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatDateInTimeZone(timestampMs, timeZone) {
|
|
299
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
300
|
+
timeZone,
|
|
301
|
+
year: 'numeric',
|
|
302
|
+
month: '2-digit',
|
|
303
|
+
day: '2-digit',
|
|
304
|
+
}).formatToParts(new Date(timestampMs));
|
|
305
|
+
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
306
|
+
return `${values.year}-${values.month}-${values.day}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeDateString(value, label) {
|
|
310
|
+
if (!DATE_RE.test(String(value))) {
|
|
311
|
+
throw new Error(`${label} must be in YYYY-MM-DD format`);
|
|
312
|
+
}
|
|
313
|
+
const [yearRaw, monthRaw, dayRaw] = String(value).split('-');
|
|
314
|
+
const year = Number(yearRaw);
|
|
315
|
+
const month = Number(monthRaw);
|
|
316
|
+
const day = Number(dayRaw);
|
|
317
|
+
const normalized = new Date(Date.UTC(year, month - 1, day));
|
|
318
|
+
if (
|
|
319
|
+
Number.isNaN(normalized.getTime()) ||
|
|
320
|
+
normalized.getUTCFullYear() !== year ||
|
|
321
|
+
normalized.getUTCMonth() !== month - 1 ||
|
|
322
|
+
normalized.getUTCDate() !== day
|
|
323
|
+
) {
|
|
324
|
+
throw new Error(`${label} must be a valid calendar date`);
|
|
325
|
+
}
|
|
326
|
+
return `${yearRaw}-${monthRaw}-${dayRaw}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function addDays(dateString, deltaDays) {
|
|
330
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
331
|
+
const shifted = new Date(Date.UTC(year, month - 1, day + deltaDays));
|
|
332
|
+
const nextYear = shifted.getUTCFullYear();
|
|
333
|
+
const nextMonth = String(shifted.getUTCMonth() + 1).padStart(2, '0');
|
|
334
|
+
const nextDay = String(shifted.getUTCDate()).padStart(2, '0');
|
|
335
|
+
return `${nextYear}-${nextMonth}-${nextDay}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isWithinDateRange(date, from, to) {
|
|
339
|
+
if (from && date < from) return false;
|
|
340
|
+
if (to && date > to) return false;
|
|
341
|
+
return true;
|
|
342
|
+
}
|
package/src/runtime-config.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_CURRENT_APP_DIR,
|
|
10
10
|
DEFAULT_HOME_BIN_LINK,
|
|
11
11
|
DEFAULT_INSTALL_ROOT,
|
|
12
|
+
DEFAULT_LAUNCH_AGENT_PATH,
|
|
12
13
|
DEFAULT_LAUNCHD_LABEL,
|
|
13
14
|
DEFAULT_LOCAL_BIN_DIR,
|
|
14
15
|
DEFAULT_LOCAL_BIN_PATH,
|
|
@@ -26,6 +27,7 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
|
|
|
26
27
|
const installRoot = path.dirname(configFile);
|
|
27
28
|
const appRoot = path.join(installRoot, 'app');
|
|
28
29
|
const currentAppDir = path.join(appRoot, 'current');
|
|
30
|
+
const launchdLabel = DEFAULT_LAUNCHD_LABEL;
|
|
29
31
|
return {
|
|
30
32
|
configFile,
|
|
31
33
|
installRoot,
|
|
@@ -44,8 +46,13 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
|
|
|
44
46
|
homeBinLink: DEFAULT_HOME_BIN_LINK,
|
|
45
47
|
stdoutLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDOUT_LOG_PATH)),
|
|
46
48
|
stderrLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDERR_LOG_PATH)),
|
|
47
|
-
launchdLabel
|
|
48
|
-
plistPath:
|
|
49
|
+
launchdLabel,
|
|
50
|
+
plistPath: path.join(installRoot, 'launchd', `${launchdLabel}.plist`),
|
|
51
|
+
launchAgentPath: path.join(
|
|
52
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
53
|
+
`${launchdLabel}.plist`,
|
|
54
|
+
),
|
|
55
|
+
autoStartOnLogin: true,
|
|
49
56
|
};
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -65,6 +72,17 @@ export function normalizeRuntimeConfig(runtime) {
|
|
|
65
72
|
const appRoot = runtime.appRoot || path.join(installRoot, 'app');
|
|
66
73
|
const currentAppDir = runtime.currentAppDir || path.join(appRoot, 'current');
|
|
67
74
|
const localBinDir = runtime.localBinDir || DEFAULT_LOCAL_BIN_DIR;
|
|
75
|
+
const launchdLabel = runtime.launchdLabel || DEFAULT_LAUNCHD_LABEL;
|
|
76
|
+
const defaultPlistPath = path.join(installRoot, 'launchd', `${launchdLabel}.plist`);
|
|
77
|
+
const defaultLaunchAgentPath = path.join(
|
|
78
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
79
|
+
`${launchdLabel}.plist`,
|
|
80
|
+
);
|
|
81
|
+
const legacyLaunchAgentPath = path.join(
|
|
82
|
+
path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
|
|
83
|
+
`${launchdLabel}.plist`,
|
|
84
|
+
);
|
|
85
|
+
const rawPlistPath = runtime.plistPath || defaultPlistPath;
|
|
68
86
|
return {
|
|
69
87
|
...runtime,
|
|
70
88
|
configFile: runtime.configFile || DEFAULT_CONFIG_FILE,
|
|
@@ -84,8 +102,14 @@ export function normalizeRuntimeConfig(runtime) {
|
|
|
84
102
|
homeBinLink: runtime.homeBinLink || DEFAULT_HOME_BIN_LINK,
|
|
85
103
|
stdoutLogPath: runtime.stdoutLogPath || path.join(installRoot, 'logs', 'stdout.log'),
|
|
86
104
|
stderrLogPath: runtime.stderrLogPath || path.join(installRoot, 'logs', 'stderr.log'),
|
|
87
|
-
launchdLabel
|
|
88
|
-
plistPath:
|
|
105
|
+
launchdLabel,
|
|
106
|
+
plistPath:
|
|
107
|
+
rawPlistPath === legacyLaunchAgentPath
|
|
108
|
+
? defaultPlistPath
|
|
109
|
+
: rawPlistPath,
|
|
110
|
+
launchAgentPath: runtime.launchAgentPath || defaultLaunchAgentPath,
|
|
111
|
+
autoStartOnLogin:
|
|
112
|
+
runtime.autoStartOnLogin === undefined ? true : Boolean(runtime.autoStartOnLogin),
|
|
89
113
|
};
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -112,6 +136,8 @@ export function saveRuntimeConfig(runtime) {
|
|
|
112
136
|
stderrLogPath: normalized.stderrLogPath,
|
|
113
137
|
launchdLabel: normalized.launchdLabel,
|
|
114
138
|
plistPath: normalized.plistPath,
|
|
139
|
+
launchAgentPath: normalized.launchAgentPath,
|
|
140
|
+
autoStartOnLogin: normalized.autoStartOnLogin,
|
|
115
141
|
})}\n`);
|
|
116
142
|
return normalized;
|
|
117
143
|
}
|
|
@@ -136,7 +162,9 @@ export function formatStatusOutput(runtime, status) {
|
|
|
136
162
|
stdoutLogPath: runtime.stdoutLogPath,
|
|
137
163
|
stderrLogPath: runtime.stderrLogPath,
|
|
138
164
|
plistPath: runtime.plistPath,
|
|
165
|
+
launchAgentPath: runtime.launchAgentPath,
|
|
139
166
|
label: runtime.launchdLabel,
|
|
167
|
+
autoStartOnLogin: runtime.autoStartOnLogin,
|
|
140
168
|
onlineThresholdSeconds: STATUS_ONLINE_THRESHOLD_SECONDS,
|
|
141
169
|
};
|
|
142
170
|
}
|
package/src/utils.js
CHANGED
|
@@ -51,3 +51,18 @@ export function computeEventUid(collectorId, relpath, lineNo) {
|
|
|
51
51
|
const raw = Buffer.from(`${collectorId}|${relpath}|${lineNo}`, 'utf8');
|
|
52
52
|
return createHash('sha1').update(raw).digest('hex');
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
export function readPersistentCollectorId(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
58
|
+
return content || null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writePersistentCollectorId(filePath, id) {
|
|
65
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
66
|
+
fs.writeFileSync(tmpPath, id, 'utf8');
|
|
67
|
+
fs.renameSync(tmpPath, filePath);
|
|
68
|
+
}
|