@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 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
- 首次运行时完成安装与配置;重复运行会升级本地版本并重启后台服务。后端地址默认为 `http://101.126.66.51:8086`,如需自定义可通过 `--backend-url` 指定。
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. 所有数据在上报前已做去敏处理,仅包含 session 元信息、turn 上下文摘要和 token 计数。
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.2",
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
- "dev": "node bin/codex-usage-uploader.js init --backend-url http://localhost:8086",
16
- "test": "node --no-warnings --test tests/*.test.mjs"
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 { formatStatusOutput, mergeRuntimeConfig } from './runtime-config.js';
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 printPathHint(runtime) {
289
- const linkDir = path.dirname(runtime.homeBinLink);
290
- const dirs = (process.env.PATH || '').split(path.delimiter);
291
- if (dirs.includes(linkDir)) return;
292
- console.log('');
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
- console.log(` export PATH="${linkDir}:$PATH"`);
297
- console.log(
298
- 'You can add this line to ~/.zshrc or ~/.bashrc to make it permanent.',
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
- const manager = cliDeps.createServiceManager(runtime);
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
- printPathHint(runtime);
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
- return stateDb.getQueueStats();
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
- ['Backend URL', payload.backendUrl ?? '-'],
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
- ['Plist', payload.plistPath],
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
- identity.collectorId = `${Date.now().toString(16)}${Math.random()
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, buildLaunchdPlist(this.runtime));
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.domainTarget(), this.runtime.plistPath], { check: false });
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
+ }
@@ -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: DEFAULT_LAUNCHD_LABEL,
48
- plistPath: DEFAULT_PLIST_PATH,
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: runtime.launchdLabel || DEFAULT_LAUNCHD_LABEL,
88
- plistPath: runtime.plistPath || DEFAULT_PLIST_PATH,
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
+ }