@marginfront/code-cost-clarity 0.6.0 → 0.6.1
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/dist/cli.d.ts +6 -1
- package/dist/cli.js +1756 -1665
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,1850 +8,1897 @@ import process3 from "process";
|
|
|
8
8
|
|
|
9
9
|
// src/connector.ts
|
|
10
10
|
import {
|
|
11
|
-
existsSync,
|
|
11
|
+
existsSync as existsSync2,
|
|
12
12
|
mkdirSync,
|
|
13
|
-
writeFileSync,
|
|
14
|
-
readFileSync,
|
|
15
|
-
openSync,
|
|
16
|
-
rmSync,
|
|
13
|
+
writeFileSync as writeFileSync2,
|
|
14
|
+
readFileSync as readFileSync2,
|
|
15
|
+
openSync as openSync2,
|
|
16
|
+
rmSync as rmSync2,
|
|
17
17
|
chmodSync,
|
|
18
18
|
copyFileSync
|
|
19
19
|
} from "fs";
|
|
20
20
|
import { spawn, spawnSync, execFileSync } from "child_process";
|
|
21
|
-
import { createHash } from "crypto";
|
|
21
|
+
import { createHash as createHash2 } from "crypto";
|
|
22
22
|
import { homedir, tmpdir, platform, arch } from "os";
|
|
23
23
|
import { join, dirname } from "path";
|
|
24
24
|
import { fileURLToPath } from "url";
|
|
25
|
-
import
|
|
26
|
-
var CONFIG_DIR = join(homedir(), ".marginfront-ccc");
|
|
27
|
-
var ENV_PATH = join(CONFIG_DIR, ".env");
|
|
28
|
-
var COLLECTOR_CONFIG_PATH = join(CONFIG_DIR, "otel-collector.yaml");
|
|
29
|
-
var COLLECTOR_BIN_PATH = join(CONFIG_DIR, "otelcol-contrib");
|
|
30
|
-
var LIVE_JSONL_PATH = join(CONFIG_DIR, "live-otlp.jsonl");
|
|
31
|
-
var COLLECTOR_PID_PATH = join(CONFIG_DIR, "collector.pid");
|
|
32
|
-
var COLLECTOR_LOG_PATH = join(CONFIG_DIR, "collector.log");
|
|
33
|
-
var FORWARDER_CURSOR_PATH = join(CONFIG_DIR, "forwarder.cursor");
|
|
34
|
-
var FORWARDER_QUEUE_PATH = join(CONFIG_DIR, "forwarder.queue.jsonl");
|
|
35
|
-
var FORWARDER_PID_PATH = join(CONFIG_DIR, "forwarder.pid");
|
|
36
|
-
var VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
|
|
37
|
-
function resolveOtelcolVersion(override = process.env.CCC_OTELCOL_VERSION) {
|
|
38
|
-
const DEFAULT_VERSION = "v0.154.0";
|
|
39
|
-
if (!override) return DEFAULT_VERSION;
|
|
40
|
-
if (!VERSION_PATTERN.test(override)) {
|
|
41
|
-
throw new Error(
|
|
42
|
-
`Invalid CCC_OTELCOL_VERSION "${override}".
|
|
43
|
-
What happened: you set the collector version override, but it isn't a valid version number.
|
|
44
|
-
Why this is strict: that value goes into a download URL and a filename, so a malformed one could point the installer somewhere unsafe. We refuse rather than guess.
|
|
45
|
-
Fix: use the form vMAJOR.MINOR.PATCH (for example CCC_OTELCOL_VERSION=v0.154.0), or unset it to use the built-in pinned version.`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
return override;
|
|
49
|
-
}
|
|
50
|
-
var OTELCOL_VERSION = resolveOtelcolVersion();
|
|
51
|
-
var COLLECTOR_CHECKSUMS = {
|
|
52
|
-
"otelcol-contrib_0.154.0_darwin_amd64.tar.gz": "14f7f825a7ad7ee0799947ff70a42b9a5528a9c1411ab45f8fcc4caeb9346f7b",
|
|
53
|
-
"otelcol-contrib_0.154.0_darwin_arm64.tar.gz": "de3af70ef0b80af213911cc9ba8daf553348dd22ed1fb15db561d207fc2cb3d9",
|
|
54
|
-
"otelcol-contrib_0.154.0_linux_amd64.tar.gz": "f0fe6e7b1d81936d4e5a3aad7a678f3fc2f8ada2a9f8f37f37542813c12ed322",
|
|
55
|
-
"otelcol-contrib_0.154.0_linux_arm64.tar.gz": "52310bfcb5a952c072e8abff7f0f2940ee38cea36752a4a4e1a4e21c2088c3f9"
|
|
56
|
-
};
|
|
57
|
-
function verifyTarballChecksum(tarballPath, expectedSha256) {
|
|
58
|
-
const actual = createHash("sha256").update(readFileSync(tarballPath)).digest("hex").toLowerCase();
|
|
59
|
-
return actual === expectedSha256.toLowerCase();
|
|
60
|
-
}
|
|
61
|
-
function renderEnvFile() {
|
|
62
|
-
return `# ============================================================
|
|
63
|
-
# Claude Code -> MarginFront connector: environment settings
|
|
64
|
-
# ============================================================
|
|
65
|
-
# MAIN JOB of this file: hold your MARGINFRONT_API_KEY (below) so the background
|
|
66
|
-
# meter can read it - fill that in (init usually does it for you).
|
|
67
|
-
#
|
|
68
|
-
# As of v0.5.0 you do NOT need to 'source' this before running Claude Code:
|
|
69
|
-
# 'init' wires the telemetry settings into ~/.claude/settings.json for you, so
|
|
70
|
-
# every 'claude'/'codex' session (and the desktop apps) broadcast on their own.
|
|
71
|
-
# Sourcing this is only useful for the manual, foreground 'run' path.
|
|
72
|
-
#
|
|
73
|
-
# This file stays on your machine; it is never published or committed.
|
|
74
|
-
# ============================================================
|
|
75
|
-
|
|
76
|
-
# Turns on Claude Code's usage broadcasting. Leave as 1.
|
|
77
|
-
CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
78
|
-
|
|
79
|
-
# Send usage as OpenTelemetry metrics. Leave as otlp.
|
|
80
|
-
OTEL_METRICS_EXPORTER=otlp
|
|
81
|
-
|
|
82
|
-
# Send Claude Code's EVENTS (the per-tool-call records) too. Leave as otlp.
|
|
83
|
-
# Tokens come in as metrics (above); a paid tool call (Google Maps, Browserbase,
|
|
84
|
-
# a paid MCP server) only shows up on this EVENT stream - without this line Claude
|
|
85
|
-
# Code broadcasts no tool activity and tool spend can't be tracked (LOWAI-478).
|
|
86
|
-
OTEL_LOGS_EXPORTER=otlp
|
|
87
|
-
|
|
88
|
-
# Use HTTP/protobuf. Verified working; gRPC on 4317 silently failed in testing.
|
|
89
|
-
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
90
|
-
|
|
91
|
-
# Where the local collector listens. Must match the collector YAML.
|
|
92
|
-
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
|
93
|
-
|
|
94
|
-
# How often Claude Code flushes usage, in milliseconds.
|
|
95
|
-
# 300000 = 5 minutes - batches usage so you get roughly one event per turn
|
|
96
|
-
# without a long wait. Raise to 600000 (10 min) to batch harder; lower to
|
|
97
|
-
# 60000 (1 min) or 5000 (5 s) for a more live drip.
|
|
98
|
-
OTEL_METRIC_EXPORT_INTERVAL=300000
|
|
99
|
-
|
|
100
|
-
# Tool-call costs (LOWAI-478) need NOTHING configured here. Every tool your agents
|
|
101
|
-
# fire (other than free built-ins like Read/Bash) is forwarded automatically;
|
|
102
|
-
# MarginFront's pricing catalog decides what's billable and at what rate. Add price
|
|
103
|
-
# rows for your paid tools in MarginFront - there's no client-side list to maintain.
|
|
104
|
-
|
|
105
|
-
# WHO this machine's AI cost belongs to (per-developer attribution).
|
|
106
|
-
# WHY THIS EXISTS, plainly: lots of teams share ONE Claude/Codex login, so every
|
|
107
|
-
# developer's usage carries the SAME login email and all the cost piles onto one
|
|
108
|
-
# person. CCC runs locally on each laptop, so naming the developer here splits that
|
|
109
|
-
# shared-login cost back out per developer. This is INTERNAL cost visibility only -
|
|
110
|
-
# it does NOT change anyone's bill.
|
|
111
|
-
#
|
|
112
|
-
# HOW IT'S USED at send time (precedence, highest wins):
|
|
113
|
-
# 1. This CCC_DEVELOPER_EMAIL, when set - it wins for EVERY record from this machine.
|
|
114
|
-
# 2. Otherwise the email the coding-agent login reports (user.email).
|
|
115
|
-
# 3. Otherwise a clearly-labeled no-identity placeholder.
|
|
116
|
-
# Leave it BLANK to keep today's behavior (auto-detect from the login). init fills
|
|
117
|
-
# this in for you, defaulting to your global git user.email - change it anytime.
|
|
118
|
-
CCC_DEVELOPER_EMAIL=
|
|
119
|
-
|
|
120
|
-
# YOUR MarginFront SECRET key (mf_sk_*). Create or copy one at:
|
|
121
|
-
# app.marginfront.com -> Build -> API Keys -> "Create Key Pair"
|
|
122
|
-
# (https://app.marginfront.com/developer-zone/api-keys)
|
|
123
|
-
# Use the SECRET key (mf_sk_*) - a publishable key (mf_pk_*) is rejected on send.
|
|
124
|
-
# The forwarder reads this from the environment only; it is never hardcoded.
|
|
125
|
-
# Leave blank to run in no-identity preview mode (see the README).
|
|
126
|
-
MARGINFRONT_API_KEY=
|
|
127
|
-
`;
|
|
128
|
-
}
|
|
129
|
-
function renderCollectorYaml(jsonlPath = LIVE_JSONL_PATH) {
|
|
130
|
-
return `# OpenTelemetry Collector config - Claude Code -> MarginFront
|
|
131
|
-
# ---------------------------------------------------------------------------
|
|
132
|
-
# WHAT THIS DOES, plainly: it's the "catcher." Claude Code broadcasts its token
|
|
133
|
-
# usage here; this collector batches what it hears and writes it to a file,
|
|
134
|
-
# which the forwarder tails and turns into MarginFront usage rows.
|
|
135
|
-
# ---------------------------------------------------------------------------
|
|
136
|
-
|
|
137
|
-
receivers:
|
|
138
|
-
# The door Claude Code knocks on. It listens for OpenTelemetry (OTLP) on the
|
|
139
|
-
# two standard local ports. We point Claude Code at HTTP/protobuf on 4318 -
|
|
140
|
-
# gRPC on 4317 silently failed to connect from Claude Code 2.1.185 in testing,
|
|
141
|
-
# so 4318 is the one that actually works. Bound to localhost only.
|
|
142
|
-
otlp:
|
|
143
|
-
protocols:
|
|
144
|
-
grpc:
|
|
145
|
-
endpoint: 127.0.0.1:4317
|
|
146
|
-
http:
|
|
147
|
-
endpoint: 127.0.0.1:4318
|
|
148
|
-
|
|
149
|
-
processors:
|
|
150
|
-
# THE NO-DOUBLE-COUNT PROCESSOR. Claude Code emits CUMULATIVE counters (each
|
|
151
|
-
# export is the running total since the session started), and it ignores the
|
|
152
|
-
# "give me deltas" env var. Without this, the forwarder would record the
|
|
153
|
-
# running total every export - re-counting everything before it. This turns
|
|
154
|
-
# each cumulative total into the increment since the last export.
|
|
155
|
-
cumulativetodelta: {}
|
|
156
|
-
|
|
157
|
-
# Group what arrives into small batches and flush quickly, so the live view
|
|
158
|
-
# feels near-real-time rather than trickling one datapoint at a time.
|
|
159
|
-
batch:
|
|
160
|
-
timeout: 1s
|
|
161
|
-
|
|
162
|
-
exporters:
|
|
163
|
-
# Write each batch as one line of OTLP/JSON (JSON Lines). The forwarder
|
|
164
|
-
# watches this file and records each new line. Both pipelines below share this
|
|
165
|
-
# one exporter, so Claude Code's metrics and Codex's logs land in the SAME file
|
|
166
|
-
# (each as its own line) and the one forwarder reads both.
|
|
167
|
-
file:
|
|
168
|
-
path: ${jsonlPath}
|
|
25
|
+
import process2 from "process";
|
|
169
26
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
receivers: [otlp]
|
|
190
|
-
processors: [batch]
|
|
191
|
-
exporters: [file]
|
|
192
|
-
`;
|
|
193
|
-
}
|
|
194
|
-
var CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
195
|
-
var CODEX_CCC_BEGIN_MARKER = "# >>> MarginFront code-cost-clarity - auto-added; `npx @marginfront/code-cost-clarity uninstall` removes this block >>>";
|
|
196
|
-
var CODEX_CCC_END_MARKER = "# <<< MarginFront code-cost-clarity - end of auto-added block <<<";
|
|
197
|
-
var CODEX_OTEL_TOML_BLOCK = [
|
|
198
|
-
"[otel]",
|
|
199
|
-
'exporter = { otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "binary" } }'
|
|
200
|
-
].join("\n");
|
|
201
|
-
function renderCodexCccBlock() {
|
|
202
|
-
return [
|
|
203
|
-
CODEX_CCC_BEGIN_MARKER,
|
|
204
|
-
CODEX_OTEL_TOML_BLOCK,
|
|
205
|
-
CODEX_CCC_END_MARKER
|
|
206
|
-
].join("\n");
|
|
207
|
-
}
|
|
208
|
-
function renderCodexConfigInstructions() {
|
|
209
|
-
return [
|
|
210
|
-
"Optional - also capture Codex (in addition to, or instead of, Claude Code):",
|
|
211
|
-
"",
|
|
212
|
-
" Codex reports its usage to the SAME local collector, so there's nothing",
|
|
213
|
-
" extra to install. Add this block to your ~/.codex/config.toml:",
|
|
214
|
-
"",
|
|
215
|
-
// Single-sourced from CODEX_OTEL_TOML_BLOCK above, indented for the printout,
|
|
216
|
-
// so the pasted block always matches what the auto-writer would write.
|
|
217
|
-
...CODEX_OTEL_TOML_BLOCK.split("\n").map((line) => " " + line),
|
|
218
|
-
"",
|
|
219
|
-
" Then run Codex normally. `run` already watches both sources - Claude Code,",
|
|
220
|
-
" Codex, or both - from the one collector file; there's no extra flag.",
|
|
221
|
-
"",
|
|
222
|
-
" Note: exact [otel] key names can vary by Codex version, and whether your",
|
|
223
|
-
" developer email shows up depends on how you sign in (org API key vs ChatGPT",
|
|
224
|
-
" login). If the email doesn't surface, usage still lands under the no-identity",
|
|
225
|
-
" placeholder. See the README's Codex section for the full details."
|
|
226
|
-
].join("\n");
|
|
227
|
-
}
|
|
228
|
-
function lineDeclaresOtelTable(rawLine) {
|
|
229
|
-
const beforeComment = rawLine.split("#")[0].trim();
|
|
230
|
-
if (!beforeComment) return false;
|
|
231
|
-
let firstToken;
|
|
232
|
-
if (beforeComment.startsWith("[")) {
|
|
233
|
-
const inner = beforeComment.replace(/^\[+/, "").trim();
|
|
234
|
-
const path = inner.split("]")[0].trim();
|
|
235
|
-
firstToken = path.split(".")[0].trim();
|
|
236
|
-
} else {
|
|
237
|
-
firstToken = beforeComment.split(/[.=\s]/)[0].trim();
|
|
27
|
+
// src/forwarder.ts
|
|
28
|
+
import {
|
|
29
|
+
readFileSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
statSync,
|
|
32
|
+
openSync,
|
|
33
|
+
readSync,
|
|
34
|
+
closeSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
appendFileSync,
|
|
37
|
+
rmSync
|
|
38
|
+
} from "fs";
|
|
39
|
+
import { createHash } from "crypto";
|
|
40
|
+
import { createRequire } from "module";
|
|
41
|
+
import process from "process";
|
|
42
|
+
var _cccRequire = createRequire(import.meta.url);
|
|
43
|
+
function resolveCccPackageVersion() {
|
|
44
|
+
if ("0.6.1".length > 0) {
|
|
45
|
+
return "0.6.1";
|
|
238
46
|
}
|
|
239
|
-
firstToken = firstToken.replace(/^["']|["']$/g, "");
|
|
240
|
-
return firstToken === "otel";
|
|
241
|
-
}
|
|
242
|
-
function codexConfigHasOtelTable(content) {
|
|
243
|
-
return content.split(/\r?\n/).some(lineDeclaresOtelTable);
|
|
244
|
-
}
|
|
245
|
-
function codexConfigHasCccBlock(configPath = CODEX_CONFIG_PATH) {
|
|
246
|
-
if (!existsSync(configPath)) return false;
|
|
247
47
|
try {
|
|
248
|
-
|
|
48
|
+
const pkg = _cccRequire("../package.json");
|
|
49
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
50
|
+
return pkg.version;
|
|
51
|
+
}
|
|
249
52
|
} catch {
|
|
250
|
-
return false;
|
|
251
53
|
}
|
|
54
|
+
return "0.0.0-unknown";
|
|
252
55
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return { action: "created", backupPath: null };
|
|
264
|
-
}
|
|
265
|
-
const content = readFileSync(configPath, "utf8");
|
|
266
|
-
if (content.includes(CODEX_CCC_BEGIN_MARKER)) {
|
|
267
|
-
log(`Codex is already wired up in ${configPath} - nothing to do.`);
|
|
268
|
-
return { action: "skipped-existing", backupPath: null };
|
|
269
|
-
}
|
|
270
|
-
if (codexConfigHasOtelTable(content)) {
|
|
271
|
-
log(
|
|
272
|
-
`Left your existing [otel] settings in ${configPath} untouched - add the MarginFront block by hand if you want Codex captured too.`
|
|
273
|
-
);
|
|
274
|
-
return { action: "skipped-existing", backupPath: null };
|
|
56
|
+
var CCC_PACKAGE_VERSION = resolveCccPackageVersion();
|
|
57
|
+
var DEFAULT_INGEST_URL = "https://api.marginfront.com/v1/sdk/usage/record";
|
|
58
|
+
function resolveIngestUrl(override = process.env.MARGINFRONT_INGEST_URL) {
|
|
59
|
+
if (!override) return DEFAULT_INGEST_URL;
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(override);
|
|
62
|
+
const host = url.hostname;
|
|
63
|
+
const onMarginFront = host === "marginfront.com" || host.endsWith(".marginfront.com");
|
|
64
|
+
if (url.protocol === "https:" && onMarginFront) return override;
|
|
65
|
+
} catch {
|
|
275
66
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const separator = content.length > 0 ? "\n" : "";
|
|
279
|
-
writeFileSync(configPath, content + separator + renderCodexCccBlock() + "\n");
|
|
280
|
-
log(
|
|
281
|
-
`Added the MarginFront telemetry block to ${configPath} (backup at ${backupPath}).`
|
|
67
|
+
console.error(
|
|
68
|
+
"Ignoring MARGINFRONT_INGEST_URL - only an https://*.marginfront.com URL is allowed; sending to production instead."
|
|
282
69
|
);
|
|
283
|
-
return
|
|
70
|
+
return DEFAULT_INGEST_URL;
|
|
284
71
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
} = {}) {
|
|
289
|
-
if (!existsSync(configPath)) {
|
|
290
|
-
log(
|
|
291
|
-
`Nothing to undo: ${configPath} doesn't exist (Codex was never wired up here).`
|
|
292
|
-
);
|
|
293
|
-
return { action: "missing", backupPath: null };
|
|
294
|
-
}
|
|
295
|
-
const content = readFileSync(configPath, "utf8");
|
|
296
|
-
const beginIdx = content.indexOf(CODEX_CCC_BEGIN_MARKER);
|
|
297
|
-
const endIdx = content.indexOf(CODEX_CCC_END_MARKER);
|
|
298
|
-
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
299
|
-
log(
|
|
300
|
-
`Nothing to undo: ${configPath} has no MarginFront block (left every other setting untouched).`
|
|
301
|
-
);
|
|
302
|
-
return { action: "no-marker", backupPath: null };
|
|
303
|
-
}
|
|
304
|
-
let cutStart = beginIdx;
|
|
305
|
-
let cutEnd = endIdx + CODEX_CCC_END_MARKER.length;
|
|
306
|
-
if (content[cutEnd] === "\n") cutEnd += 1;
|
|
307
|
-
if (content[cutStart - 1] === "\n") {
|
|
308
|
-
cutStart -= 1;
|
|
309
|
-
}
|
|
310
|
-
const backupPath = existsSync(`${configPath}.ccc-bak`) ? `${configPath}.ccc-bak` : null;
|
|
311
|
-
writeFileSync(configPath, content.slice(0, cutStart) + content.slice(cutEnd));
|
|
312
|
-
log(
|
|
313
|
-
`Removed the MarginFront block from ${configPath}` + (backupPath ? ` (your original is preserved at ${backupPath}).` : ".")
|
|
314
|
-
);
|
|
315
|
-
return { action: "removed", backupPath };
|
|
72
|
+
var MARGINFRONT_INGEST_URL = resolveIngestUrl();
|
|
73
|
+
function resolveDeveloperOverride(env) {
|
|
74
|
+
return (env.CCC_DEVELOPER_EMAIL || "").trim() || void 0;
|
|
316
75
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
76
|
+
var AGENT_CODE = "claude-code";
|
|
77
|
+
var SIGNAL_NAME = "claude-code-turn";
|
|
78
|
+
var MODEL_PROVIDER = "anthropic";
|
|
79
|
+
var AGENT_CODE_CODEX = "codex";
|
|
80
|
+
var SIGNAL_NAME_CODEX = "codex-turn";
|
|
81
|
+
var MODEL_PROVIDER_CODEX = "openai";
|
|
82
|
+
var CODEX_USAGE_EVENT_NAME = "codex.sse_event";
|
|
83
|
+
var CODEX_INPUT_TOKEN_KEY = "input_token_count";
|
|
84
|
+
var CODEX_OUTPUT_TOKEN_KEY = "output_token_count";
|
|
85
|
+
var CODEX_CACHED_TOKEN_KEY = "cached_token_count";
|
|
86
|
+
var CODEX_REASONING_TOKEN_KEY = "reasoning_token_count";
|
|
87
|
+
var CODEX_TOOL_TOKEN_KEY = "tool_token_count";
|
|
88
|
+
var CODEX_TOKEN_KEYS = [
|
|
89
|
+
CODEX_INPUT_TOKEN_KEY,
|
|
90
|
+
CODEX_OUTPUT_TOKEN_KEY,
|
|
91
|
+
CODEX_CACHED_TOKEN_KEY,
|
|
92
|
+
CODEX_REASONING_TOKEN_KEY
|
|
93
|
+
];
|
|
94
|
+
var CODEX_EXCLUDED_MODELS = /* @__PURE__ */ new Set(["codex-auto-review"]);
|
|
95
|
+
var SIGNAL_NAME_TOOL = "claude-code-tool";
|
|
96
|
+
var SIGNAL_NAME_TOOL_CODEX = "codex-tool";
|
|
97
|
+
var CLAUDE_TOOL_RESULT_EVENT = "claude_code.tool_result";
|
|
98
|
+
var CODEX_TOOL_RESULT_EVENT = "codex.tool_result";
|
|
99
|
+
var TOOL_NAME_KEYS = ["tool_name", "tool.name"];
|
|
100
|
+
var MODEL_PROVIDER_TOOL = "tool";
|
|
101
|
+
var BUILTIN_NONBILLABLE_TOOLS = /* @__PURE__ */ new Set([
|
|
102
|
+
// Claude Code built-ins
|
|
103
|
+
"Read",
|
|
104
|
+
"Edit",
|
|
105
|
+
"Write",
|
|
106
|
+
"MultiEdit",
|
|
107
|
+
"NotebookEdit",
|
|
108
|
+
"NotebookRead",
|
|
109
|
+
"Bash",
|
|
110
|
+
"BashOutput",
|
|
111
|
+
"KillShell",
|
|
112
|
+
"KillBash",
|
|
113
|
+
"Grep",
|
|
114
|
+
"Glob",
|
|
115
|
+
"LS",
|
|
116
|
+
"TodoWrite",
|
|
117
|
+
"ExitPlanMode",
|
|
118
|
+
"Task",
|
|
119
|
+
"Agent",
|
|
120
|
+
// alternate name for Task - hedge against the rename
|
|
121
|
+
"SlashCommand",
|
|
122
|
+
"ToolSearch",
|
|
123
|
+
// Codex built-ins (free + high-volume)
|
|
124
|
+
"exec_command",
|
|
125
|
+
"apply_patch"
|
|
126
|
+
]);
|
|
127
|
+
var ENVIRONMENT = "development";
|
|
128
|
+
var WATCH_POLL_MS = 1e3;
|
|
129
|
+
var NO_IDENTITY_CUSTOMER = "claude-code-no-identity";
|
|
130
|
+
var CODEX_NO_IDENTITY_CUSTOMER = "codex-no-identity";
|
|
131
|
+
function attributesToLookup(attributeList) {
|
|
132
|
+
const lookup = {};
|
|
133
|
+
if (!Array.isArray(attributeList)) return lookup;
|
|
134
|
+
for (const attribute of attributeList) {
|
|
135
|
+
if (attribute && typeof attribute.key === "string" && attribute.value) {
|
|
136
|
+
lookup[attribute.key] = attribute.value.stringValue;
|
|
137
|
+
}
|
|
331
138
|
}
|
|
332
|
-
|
|
333
|
-
const asset = `otelcol-contrib_${versionNoV}_${os}_${cpu}.tar.gz`;
|
|
334
|
-
const url = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/${version}/${asset}`;
|
|
335
|
-
return { os, cpu, asset, url, version };
|
|
139
|
+
return lookup;
|
|
336
140
|
}
|
|
337
|
-
function
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
log(
|
|
343
|
-
`Collector already present at ${COLLECTOR_BIN_PATH} - skipping download.`
|
|
344
|
-
);
|
|
345
|
-
return COLLECTOR_BIN_PATH;
|
|
346
|
-
}
|
|
347
|
-
const { asset, url, version } = resolveCollectorAsset();
|
|
348
|
-
const tgzPath = join(tmpdir(), asset);
|
|
349
|
-
log(
|
|
350
|
-
`Downloading the collector (${asset}, ~360 MB) - this can take a minute\u2026`
|
|
351
|
-
);
|
|
352
|
-
try {
|
|
353
|
-
execFileSync("curl", ["-fSL", "-o", tgzPath, url], { stdio: "inherit" });
|
|
354
|
-
} catch {
|
|
355
|
-
throw new Error(
|
|
356
|
-
`Could not download the collector from ${url}
|
|
357
|
-
What happened: the download failed (network issue, or version ${version} has no asset for your platform).
|
|
358
|
-
Fix: check your internet connection, or pin a different version with CCC_OTELCOL_VERSION=v0.155.0 (see the OpenTelemetry collector releases page).`
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
const expectedSha256 = COLLECTOR_CHECKSUMS[asset];
|
|
362
|
-
if (!expectedSha256) {
|
|
363
|
-
throw new Error(
|
|
364
|
-
`Refusing to run an unverified collector.
|
|
365
|
-
What happened: there is no pinned, known-good fingerprint on file for "${asset}" (version ${version}).
|
|
366
|
-
Why: this tool refuses to make ANY downloaded program executable unless we can first confirm it's exactly the release we trust.
|
|
367
|
-
Fix: install the built-in pinned version (unset CCC_OTELCOL_VERSION), or add this release's official signed SHA-256 to the package's pinned checksums before installing it. The download is left at ${tgzPath} so you can verify it yourself.`
|
|
368
|
-
);
|
|
141
|
+
function dataPointTokenCount(dataPoint) {
|
|
142
|
+
if (dataPoint == null) return 0;
|
|
143
|
+
if (dataPoint.asInt !== void 0 && dataPoint.asInt !== null) {
|
|
144
|
+
const parsed = parseInt(String(dataPoint.asInt), 10);
|
|
145
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
369
146
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
rmSync(tgzPath, { force: true });
|
|
374
|
-
} catch {
|
|
375
|
-
}
|
|
376
|
-
throw new Error(
|
|
377
|
-
`Collector integrity check FAILED - refusing to install.
|
|
378
|
-
What happened: the collector we downloaded for "${asset}" does NOT match its known-good fingerprint, so we deleted it and stopped.
|
|
379
|
-
Why this probably happened: the file was altered between GitHub and your machine - most often a corporate TLS-inspecting proxy rewriting the download, a corrupted transfer, or (worst case) a tampered release.
|
|
380
|
-
Fix: re-run the install on a network without a man-in-the-middle proxy. If it keeps failing on a clean network, do NOT bypass this - report it, because a persistent mismatch can mean the download is being tampered with.`
|
|
381
|
-
);
|
|
147
|
+
if (dataPoint.asDouble !== void 0 && dataPoint.asDouble !== null) {
|
|
148
|
+
const parsed = Math.round(Number(dataPoint.asDouble));
|
|
149
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
382
150
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
function collectDataPointsForMetric(parsedOtlp, metricName) {
|
|
154
|
+
const collectedDataPoints = [];
|
|
155
|
+
const resourceMetricsList = parsedOtlp?.resourceMetrics;
|
|
156
|
+
if (!Array.isArray(resourceMetricsList)) return collectedDataPoints;
|
|
157
|
+
for (const resourceMetric of resourceMetricsList) {
|
|
158
|
+
const scopeMetricsList = resourceMetric?.scopeMetrics;
|
|
159
|
+
if (!Array.isArray(scopeMetricsList)) continue;
|
|
160
|
+
for (const scopeMetric of scopeMetricsList) {
|
|
161
|
+
const metricsList = scopeMetric?.metrics;
|
|
162
|
+
if (!Array.isArray(metricsList)) continue;
|
|
163
|
+
for (const metric of metricsList) {
|
|
164
|
+
if (metric?.name !== metricName) continue;
|
|
165
|
+
const dataPointsList = metric?.sum?.dataPoints;
|
|
166
|
+
if (!Array.isArray(dataPointsList)) continue;
|
|
167
|
+
for (const dataPoint of dataPointsList) {
|
|
168
|
+
collectedDataPoints.push(dataPoint);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
399
171
|
}
|
|
400
172
|
}
|
|
401
|
-
|
|
402
|
-
execFileSync("chmod", ["755", COLLECTOR_BIN_PATH]);
|
|
403
|
-
} catch {
|
|
404
|
-
}
|
|
405
|
-
log(`Collector installed at ${COLLECTOR_BIN_PATH}.`);
|
|
406
|
-
return COLLECTOR_BIN_PATH;
|
|
173
|
+
return collectedDataPoints;
|
|
407
174
|
}
|
|
408
|
-
function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
log(`Kept your existing settings file at ${ENV_PATH} (API key preserved).`);
|
|
417
|
-
} else {
|
|
418
|
-
writeFileSync(ENV_PATH, renderEnvFile(), { mode: 384 });
|
|
419
|
-
log(`Wrote settings template to ${ENV_PATH} - add your API key there.`);
|
|
420
|
-
}
|
|
421
|
-
writeFileSync(COLLECTOR_CONFIG_PATH, renderCollectorYaml());
|
|
422
|
-
log(`Wrote collector config to ${COLLECTOR_CONFIG_PATH}.`);
|
|
175
|
+
function normalizeModelId(rawModelId) {
|
|
176
|
+
if (typeof rawModelId !== "string") return rawModelId;
|
|
177
|
+
const withoutContextSuffix = rawModelId.replace(/\[.*\]$/, "");
|
|
178
|
+
const dottedVersion = withoutContextSuffix.replace(
|
|
179
|
+
/^(claude-[a-z]+-\d+)-(\d+)$/,
|
|
180
|
+
"$1.$2"
|
|
181
|
+
);
|
|
182
|
+
return dottedVersion;
|
|
423
183
|
}
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
184
|
+
function idempotencyKeyFor(rawSourceLine, email, rawModel, sessionId, indexWithinLine) {
|
|
185
|
+
return createHash("sha256").update(
|
|
186
|
+
`${rawSourceLine}|${email}|${rawModel}|${sessionId ?? ""}|${indexWithinLine}`
|
|
187
|
+
).digest("hex");
|
|
188
|
+
}
|
|
189
|
+
function buildMarginFrontRequestBody(parsedOtlp, options = {}) {
|
|
190
|
+
const foldCache = options.foldCache === true;
|
|
191
|
+
const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : null;
|
|
192
|
+
const developerOverride = options.developerOverride;
|
|
193
|
+
const tokenDataPoints = collectDataPointsForMetric(
|
|
194
|
+
parsedOtlp,
|
|
195
|
+
"claude_code.token.usage"
|
|
196
|
+
);
|
|
197
|
+
const costDataPoints = collectDataPointsForMetric(
|
|
198
|
+
parsedOtlp,
|
|
199
|
+
"claude_code.cost.usage"
|
|
200
|
+
);
|
|
201
|
+
const groupsByKey = /* @__PURE__ */ new Map();
|
|
202
|
+
function makeGroupKey(email, model, sessionId) {
|
|
203
|
+
return `${email}
|
|
204
|
+
${model}
|
|
205
|
+
${sessionId}`;
|
|
432
206
|
}
|
|
433
|
-
for (const
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
|
|
207
|
+
for (const dataPoint of tokenDataPoints) {
|
|
208
|
+
const attributes = attributesToLookup(dataPoint.attributes);
|
|
209
|
+
const email = developerOverride || attributes["user.email"] || fallbackCustomerId;
|
|
210
|
+
const rawModel = attributes["model"];
|
|
211
|
+
const sessionId = attributes["session.id"];
|
|
212
|
+
const tokenType = attributes["type"];
|
|
213
|
+
const tokenCount = dataPointTokenCount(dataPoint);
|
|
214
|
+
if (!email || !rawModel || !sessionId) continue;
|
|
215
|
+
const groupKey = makeGroupKey(email, rawModel, sessionId);
|
|
216
|
+
let group = groupsByKey.get(groupKey);
|
|
217
|
+
if (!group) {
|
|
218
|
+
group = {
|
|
219
|
+
email,
|
|
220
|
+
rawModel,
|
|
221
|
+
sessionId,
|
|
222
|
+
inputTokens: 0,
|
|
223
|
+
outputTokens: 0,
|
|
224
|
+
cacheReadTokens: 0,
|
|
225
|
+
cacheCreationTokens: 0
|
|
226
|
+
};
|
|
227
|
+
groupsByKey.set(groupKey, group);
|
|
442
228
|
}
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
229
|
+
if (tokenType === "input") {
|
|
230
|
+
group.inputTokens += tokenCount;
|
|
231
|
+
} else if (tokenType === "output") {
|
|
232
|
+
group.outputTokens += tokenCount;
|
|
233
|
+
} else if (tokenType === "cacheRead") {
|
|
234
|
+
group.cacheReadTokens += tokenCount;
|
|
235
|
+
} else if (tokenType === "cacheCreation") {
|
|
236
|
+
group.cacheCreationTokens += tokenCount;
|
|
447
237
|
}
|
|
448
238
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const sep = current.endsWith("\n") || current === "" ? "" : "\n";
|
|
459
|
-
next = `${current}${sep}MARGINFRONT_API_KEY=${value}
|
|
460
|
-
`;
|
|
239
|
+
function findCostForGroup(email, rawModel, sessionId) {
|
|
240
|
+
for (const costPoint of costDataPoints) {
|
|
241
|
+
const costAttributes = attributesToLookup(costPoint.attributes);
|
|
242
|
+
const costEmail = developerOverride || costAttributes["user.email"] || fallbackCustomerId;
|
|
243
|
+
if (costEmail === email && costAttributes["model"] === rawModel && costAttributes["session.id"] === sessionId) {
|
|
244
|
+
return typeof costPoint.asDouble === "number" ? costPoint.asDouble : null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
461
248
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
249
|
+
const records = [];
|
|
250
|
+
for (const group of groupsByKey.values()) {
|
|
251
|
+
const cacheReadTokens = group.cacheReadTokens;
|
|
252
|
+
const cacheCreationTokens = group.cacheCreationTokens;
|
|
253
|
+
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
|
254
|
+
const billedInputTokens = foldCache ? group.inputTokens + cacheTokens : group.inputTokens;
|
|
255
|
+
if (group.inputTokens === 0 && group.outputTokens === 0 && cacheTokens === 0)
|
|
256
|
+
continue;
|
|
257
|
+
const claudeCodeCostUsd = findCostForGroup(
|
|
258
|
+
group.email,
|
|
259
|
+
group.rawModel,
|
|
260
|
+
group.sessionId
|
|
261
|
+
);
|
|
262
|
+
const record = {
|
|
263
|
+
// Per-developer attribution: the developer's email IS the customer id (or the
|
|
264
|
+
// no-identity placeholder).
|
|
265
|
+
customerExternalId: group.email,
|
|
266
|
+
agentCode: AGENT_CODE,
|
|
267
|
+
signalName: SIGNAL_NAME,
|
|
268
|
+
model: normalizeModelId(group.rawModel),
|
|
269
|
+
modelProvider: MODEL_PROVIDER,
|
|
270
|
+
inputTokens: billedInputTokens,
|
|
271
|
+
outputTokens: group.outputTokens,
|
|
272
|
+
environment: ENVIRONMENT,
|
|
273
|
+
// usageDate omitted so MarginFront defaults it to "now."
|
|
274
|
+
metadata: {
|
|
275
|
+
sessionId: group.sessionId,
|
|
276
|
+
rawModel: group.rawModel,
|
|
277
|
+
// Raw cache numbers ALWAYS recorded (audit), whether typed or folded.
|
|
278
|
+
cacheReadTokens,
|
|
279
|
+
cacheCreationTokens,
|
|
280
|
+
// Whether inputTokens includes cache (fold) or not (default).
|
|
281
|
+
cacheFoldedIntoInput: foldCache,
|
|
282
|
+
// Claude Code's own cache-accurate cost - reconciliation ground truth,
|
|
283
|
+
// not the billed figure.
|
|
284
|
+
claudeCodeCostUsd
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
if (!foldCache) {
|
|
288
|
+
record.cacheReadTokens = cacheReadTokens;
|
|
289
|
+
record.cacheWriteTokens = cacheCreationTokens;
|
|
290
|
+
}
|
|
291
|
+
if (options.rawSourceLine !== void 0) {
|
|
292
|
+
record.idempotencyKey = idempotencyKeyFor(
|
|
293
|
+
options.rawSourceLine,
|
|
294
|
+
group.email,
|
|
295
|
+
group.rawModel,
|
|
296
|
+
group.sessionId,
|
|
297
|
+
records.length
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
records.push(record);
|
|
475
301
|
}
|
|
302
|
+
return { records };
|
|
476
303
|
}
|
|
477
|
-
function
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const sep = current.endsWith("\n") || current === "" ? "" : "\n";
|
|
485
|
-
next = `${current}${sep}CCC_DEVELOPER_EMAIL=${value}
|
|
486
|
-
`;
|
|
304
|
+
function logAttributesToLookup(attributeList) {
|
|
305
|
+
const lookup = {};
|
|
306
|
+
if (!Array.isArray(attributeList)) return lookup;
|
|
307
|
+
for (const attribute of attributeList) {
|
|
308
|
+
if (attribute && typeof attribute.key === "string" && attribute.value) {
|
|
309
|
+
lookup[attribute.key] = attribute.value;
|
|
310
|
+
}
|
|
487
311
|
}
|
|
488
|
-
|
|
489
|
-
chmodSync(path, 384);
|
|
490
|
-
}
|
|
491
|
-
var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
492
|
-
var CLAUDE_SETTINGS_OWNERSHIP_PATH = join(
|
|
493
|
-
CONFIG_DIR,
|
|
494
|
-
"claude-settings-owned.json"
|
|
495
|
-
);
|
|
496
|
-
var CCC_OWNED_TELEMETRY_KEYS = [
|
|
497
|
-
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
498
|
-
"OTEL_METRICS_EXPORTER",
|
|
499
|
-
"OTEL_LOGS_EXPORTER",
|
|
500
|
-
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
501
|
-
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
502
|
-
"OTEL_METRIC_EXPORT_INTERVAL"
|
|
503
|
-
];
|
|
504
|
-
var CCC_TELEMETRY_VALUES = {
|
|
505
|
-
// Turns on Claude Code's usage broadcasting.
|
|
506
|
-
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
507
|
-
// Send token usage as OpenTelemetry metrics.
|
|
508
|
-
OTEL_METRICS_EXPORTER: "otlp",
|
|
509
|
-
// Send the per-tool-call EVENT stream too (needed for tool-call line items).
|
|
510
|
-
OTEL_LOGS_EXPORTER: "otlp",
|
|
511
|
-
// HTTP/protobuf - verified working; gRPC on 4317 silently failed in testing.
|
|
512
|
-
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
|
|
513
|
-
// Point Claude Code at the local collector (must match the collector YAML).
|
|
514
|
-
OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318",
|
|
515
|
-
// Flush usage every 5 minutes (batched ~one event per turn).
|
|
516
|
-
OTEL_METRIC_EXPORT_INTERVAL: "300000"
|
|
517
|
-
};
|
|
518
|
-
function isPlainObject(value) {
|
|
519
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
312
|
+
return lookup;
|
|
520
313
|
}
|
|
521
|
-
function
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return backupPath;
|
|
314
|
+
function logAttrString(value) {
|
|
315
|
+
if (value && typeof value.stringValue === "string") return value.stringValue;
|
|
316
|
+
return void 0;
|
|
525
317
|
}
|
|
526
|
-
function
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
const parsed =
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
return null;
|
|
318
|
+
function logAttrTokenCount(value) {
|
|
319
|
+
if (value == null) return 0;
|
|
320
|
+
if (value.intValue !== void 0 && value.intValue !== null) {
|
|
321
|
+
const parsed = parseInt(String(value.intValue), 10);
|
|
322
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
323
|
+
}
|
|
324
|
+
if (value.doubleValue !== void 0 && value.doubleValue !== null) {
|
|
325
|
+
const parsed = Math.round(Number(value.doubleValue));
|
|
326
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
327
|
+
}
|
|
328
|
+
if (typeof value.stringValue === "string") {
|
|
329
|
+
const parsed = parseInt(value.stringValue, 10);
|
|
330
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
540
331
|
}
|
|
332
|
+
return 0;
|
|
541
333
|
}
|
|
542
|
-
function
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
_comment: "MarginFront code-cost-clarity wrote these telemetry keys into your Claude Code settings.json. Uninstall removes ONLY the keys below, and only if their value still matches. Safe to delete by hand if you've already cleaned up settings.json yourself.",
|
|
546
|
-
ownedKeys
|
|
547
|
-
};
|
|
548
|
-
writeFileSync(ownershipPath, JSON.stringify(payload, null, 2) + "\n");
|
|
334
|
+
function isCodexUsageRecord(lookup, eventName) {
|
|
335
|
+
if (eventName === CODEX_USAGE_EVENT_NAME) return true;
|
|
336
|
+
return CODEX_TOKEN_KEYS.some((key) => lookup[key] !== void 0);
|
|
549
337
|
}
|
|
550
|
-
function
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
338
|
+
function buildCodexRequestBody(parsedOtlp, options = {}) {
|
|
339
|
+
const foldCache = options.foldCache === true;
|
|
340
|
+
const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : CODEX_NO_IDENTITY_CUSTOMER;
|
|
341
|
+
const developerOverride = options.developerOverride;
|
|
342
|
+
const records = [];
|
|
343
|
+
const resourceLogsList = parsedOtlp?.resourceLogs;
|
|
344
|
+
if (!Array.isArray(resourceLogsList)) return { records };
|
|
345
|
+
for (const resourceLog of resourceLogsList) {
|
|
346
|
+
const scopeLogsList = resourceLog?.scopeLogs;
|
|
347
|
+
if (!Array.isArray(scopeLogsList)) continue;
|
|
348
|
+
for (const scopeLog of scopeLogsList) {
|
|
349
|
+
const logRecordsList = scopeLog?.logRecords;
|
|
350
|
+
if (!Array.isArray(logRecordsList)) continue;
|
|
351
|
+
for (const logRecord of logRecordsList) {
|
|
352
|
+
const lookup = logAttributesToLookup(logRecord?.attributes);
|
|
353
|
+
const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
|
|
354
|
+
if (toolResultSource(eventName) !== null) continue;
|
|
355
|
+
if (!isCodexUsageRecord(lookup, eventName)) continue;
|
|
356
|
+
const rawModel = logAttrString(lookup["model"]);
|
|
357
|
+
if (!rawModel) continue;
|
|
358
|
+
if (CODEX_EXCLUDED_MODELS.has(rawModel.trim().toLowerCase())) continue;
|
|
359
|
+
const email = developerOverride || logAttrString(lookup["user.email"]) || fallbackCustomerId;
|
|
360
|
+
if (!email) continue;
|
|
361
|
+
const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
|
|
362
|
+
const inputCount = logAttrTokenCount(lookup[CODEX_INPUT_TOKEN_KEY]);
|
|
363
|
+
const outputCount = logAttrTokenCount(lookup[CODEX_OUTPUT_TOKEN_KEY]);
|
|
364
|
+
const cachedCount = logAttrTokenCount(lookup[CODEX_CACHED_TOKEN_KEY]);
|
|
365
|
+
const reasoningCount = logAttrTokenCount(
|
|
366
|
+
lookup[CODEX_REASONING_TOKEN_KEY]
|
|
367
|
+
);
|
|
368
|
+
const toolCount = logAttrTokenCount(lookup[CODEX_TOOL_TOKEN_KEY]);
|
|
369
|
+
if (inputCount === 0 && outputCount === 0 && cachedCount === 0)
|
|
370
|
+
continue;
|
|
371
|
+
const freshInputTokens = Math.max(0, inputCount - cachedCount);
|
|
372
|
+
const billedInputTokens = foldCache ? inputCount : freshInputTokens;
|
|
373
|
+
const record = {
|
|
374
|
+
customerExternalId: email,
|
|
375
|
+
agentCode: AGENT_CODE_CODEX,
|
|
376
|
+
signalName: SIGNAL_NAME_CODEX,
|
|
377
|
+
model: normalizeModelId(rawModel),
|
|
378
|
+
modelProvider: MODEL_PROVIDER_CODEX,
|
|
379
|
+
inputTokens: billedInputTokens,
|
|
380
|
+
// Reasoning is ALREADY inside this number - do NOT add it again.
|
|
381
|
+
outputTokens: outputCount,
|
|
382
|
+
environment: ENVIRONMENT,
|
|
383
|
+
// usageDate omitted so MarginFront defaults it to "now."
|
|
384
|
+
metadata: {
|
|
385
|
+
source: "codex",
|
|
386
|
+
sessionId,
|
|
387
|
+
rawModel,
|
|
388
|
+
// Audit-only - nested inside input/output already, never added to the
|
|
389
|
+
// billed tokens; recorded to prove we didn't drop anything.
|
|
390
|
+
cachedTokens: cachedCount,
|
|
391
|
+
reasoningTokens: reasoningCount,
|
|
392
|
+
toolTokens: toolCount,
|
|
393
|
+
cacheFoldedIntoInput: foldCache
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
if (!foldCache) {
|
|
397
|
+
record.cacheReadTokens = cachedCount;
|
|
398
|
+
}
|
|
399
|
+
if (options.rawSourceLine !== void 0) {
|
|
400
|
+
record.idempotencyKey = idempotencyKeyFor(
|
|
401
|
+
options.rawSourceLine,
|
|
402
|
+
email,
|
|
403
|
+
rawModel,
|
|
404
|
+
sessionId,
|
|
405
|
+
records.length
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
records.push(record);
|
|
409
|
+
}
|
|
578
410
|
}
|
|
579
|
-
settings = parsed;
|
|
580
411
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
);
|
|
412
|
+
return { records };
|
|
413
|
+
}
|
|
414
|
+
function toolResultSource(eventName) {
|
|
415
|
+
if (!eventName) return null;
|
|
416
|
+
if (eventName === CODEX_TOOL_RESULT_EVENT) return "codex";
|
|
417
|
+
if (eventName === CLAUDE_TOOL_RESULT_EVENT || eventName === "tool_result") {
|
|
418
|
+
return "claude-code";
|
|
589
419
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
if (
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
function buildToolUsageRecords(parsedOtlp, options = {}) {
|
|
423
|
+
const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : CODEX_NO_IDENTITY_CUSTOMER;
|
|
424
|
+
const developerOverride = options.developerOverride;
|
|
425
|
+
const records = [];
|
|
426
|
+
const resourceLogsList = parsedOtlp?.resourceLogs;
|
|
427
|
+
if (!Array.isArray(resourceLogsList)) return { records };
|
|
428
|
+
const groups = /* @__PURE__ */ new Map();
|
|
429
|
+
for (const resourceLog of resourceLogsList) {
|
|
430
|
+
const scopeLogsList = resourceLog?.scopeLogs;
|
|
431
|
+
if (!Array.isArray(scopeLogsList)) continue;
|
|
432
|
+
for (const scopeLog of scopeLogsList) {
|
|
433
|
+
const logRecordsList = scopeLog?.logRecords;
|
|
434
|
+
if (!Array.isArray(logRecordsList)) continue;
|
|
435
|
+
for (const logRecord of logRecordsList) {
|
|
436
|
+
const lookup = logAttributesToLookup(logRecord?.attributes);
|
|
437
|
+
const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
|
|
438
|
+
const source = toolResultSource(eventName);
|
|
439
|
+
if (!source) continue;
|
|
440
|
+
let toolName;
|
|
441
|
+
for (const key of TOOL_NAME_KEYS) {
|
|
442
|
+
const candidate = logAttrString(lookup[key]);
|
|
443
|
+
if (candidate) {
|
|
444
|
+
toolName = candidate;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!toolName) continue;
|
|
449
|
+
if (BUILTIN_NONBILLABLE_TOOLS.has(toolName)) continue;
|
|
450
|
+
const email = developerOverride || logAttrString(lookup["user.email"]) || fallbackCustomerId;
|
|
451
|
+
if (!email) continue;
|
|
452
|
+
const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
|
|
453
|
+
const groupKey = `${email}
|
|
454
|
+
${sessionId ?? ""}
|
|
455
|
+
${source}
|
|
456
|
+
${toolName}`;
|
|
457
|
+
let group = groups.get(groupKey);
|
|
458
|
+
if (!group) {
|
|
459
|
+
group = {
|
|
460
|
+
email,
|
|
461
|
+
sessionId,
|
|
462
|
+
telemetrySource: source,
|
|
463
|
+
toolName,
|
|
464
|
+
count: 0
|
|
465
|
+
};
|
|
466
|
+
groups.set(groupKey, group);
|
|
467
|
+
}
|
|
468
|
+
group.count += 1;
|
|
612
469
|
}
|
|
613
|
-
} else {
|
|
614
|
-
skipped.push(key);
|
|
615
470
|
}
|
|
616
471
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
472
|
+
for (const group of groups.values()) {
|
|
473
|
+
if (group.count === 0) continue;
|
|
474
|
+
const isCodex = group.telemetrySource === "codex";
|
|
475
|
+
const record = {
|
|
476
|
+
customerExternalId: group.email,
|
|
477
|
+
agentCode: isCodex ? AGENT_CODE_CODEX : AGENT_CODE,
|
|
478
|
+
signalName: isCodex ? SIGNAL_NAME_TOOL_CODEX : SIGNAL_NAME_TOOL,
|
|
479
|
+
// Raw tool NAME as the service id, verbatim - NOT through normalizeModelId
|
|
480
|
+
// (that's Claude-LLM-name-specific). Catalog matches (model=tool name,
|
|
481
|
+
// modelProvider="tool").
|
|
482
|
+
model: group.toolName,
|
|
483
|
+
modelProvider: MODEL_PROVIDER_TOOL,
|
|
484
|
+
// Billing volume = times this tool fired this flush; no token fields (not an
|
|
485
|
+
// LLM turn). Server prices quantity x unitPrice.
|
|
486
|
+
quantity: group.count,
|
|
487
|
+
environment: ENVIRONMENT,
|
|
488
|
+
metadata: {
|
|
489
|
+
source: "tool",
|
|
490
|
+
sessionId: group.sessionId,
|
|
491
|
+
toolName: group.toolName,
|
|
492
|
+
telemetrySource: group.telemetrySource,
|
|
493
|
+
estimated: true
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
if (options.rawSourceLine !== void 0) {
|
|
497
|
+
record.idempotencyKey = idempotencyKeyFor(
|
|
498
|
+
options.rawSourceLine,
|
|
499
|
+
group.email,
|
|
500
|
+
`tool:${group.toolName}`,
|
|
501
|
+
group.sessionId,
|
|
502
|
+
records.length
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
records.push(record);
|
|
628
506
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
507
|
+
return { records };
|
|
508
|
+
}
|
|
509
|
+
function buildRequestBodyForLine(parsedOtlp, options = {}) {
|
|
510
|
+
if (parsedOtlp && Array.isArray(parsedOtlp.resourceLogs)) {
|
|
511
|
+
const codexOptions = options.fallbackCustomerId === NO_IDENTITY_CUSTOMER ? { ...options, fallbackCustomerId: CODEX_NO_IDENTITY_CUSTOMER } : options;
|
|
512
|
+
const tokenBody = buildCodexRequestBody(parsedOtlp, codexOptions);
|
|
513
|
+
const toolBody = buildToolUsageRecords(parsedOtlp, codexOptions);
|
|
514
|
+
return { records: [...tokenBody.records, ...toolBody.records] };
|
|
634
515
|
}
|
|
635
|
-
return
|
|
516
|
+
return buildMarginFrontRequestBody(parsedOtlp, options);
|
|
636
517
|
}
|
|
637
|
-
function
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
} = {}) {
|
|
642
|
-
const removed = [];
|
|
643
|
-
const kept = [];
|
|
644
|
-
const owned = readClaudeSettingsOwnership(ownershipPath);
|
|
645
|
-
if (owned === null) {
|
|
646
|
-
log(
|
|
647
|
-
`Nothing to undo: no MarginFront ownership record found at ${ownershipPath} (either it was never written, or it's already been removed).`
|
|
518
|
+
function readAndParseOtlpFile(inputFilePath) {
|
|
519
|
+
if (!existsSync(inputFilePath)) {
|
|
520
|
+
console.error(
|
|
521
|
+
`Could not read/parse ${inputFilePath}: file does not exist. Check the path and try again.`
|
|
648
522
|
);
|
|
649
|
-
|
|
523
|
+
process.exit(1);
|
|
650
524
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
525
|
+
let fileText;
|
|
526
|
+
try {
|
|
527
|
+
fileText = readFileSync(inputFilePath, "utf8");
|
|
528
|
+
} catch (readError) {
|
|
529
|
+
console.error(
|
|
530
|
+
`Could not read/parse ${inputFilePath}: ${readError.message}`
|
|
654
531
|
);
|
|
655
|
-
|
|
656
|
-
return { removed, kept, backupPath: null };
|
|
532
|
+
process.exit(1);
|
|
657
533
|
}
|
|
658
|
-
let parsed;
|
|
659
534
|
try {
|
|
660
|
-
|
|
535
|
+
return JSON.parse(fileText);
|
|
661
536
|
} catch {
|
|
662
|
-
|
|
663
|
-
`Could not
|
|
537
|
+
console.error(
|
|
538
|
+
`Could not read/parse ${inputFilePath}: the file is not valid JSON. Make sure it is an OTLP/JSON export line (Claude Code metrics or Codex logs), not raw text.`
|
|
664
539
|
);
|
|
665
|
-
|
|
540
|
+
process.exit(1);
|
|
666
541
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
return {
|
|
542
|
+
}
|
|
543
|
+
async function postToMarginFront(requestBody) {
|
|
544
|
+
const apiKey = process.env.MARGINFRONT_API_KEY;
|
|
545
|
+
if (!apiKey) {
|
|
546
|
+
return { ok: false, reason: "no-key" };
|
|
672
547
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
548
|
+
let response;
|
|
549
|
+
try {
|
|
550
|
+
response = await fetch(MARGINFRONT_INGEST_URL, {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: {
|
|
553
|
+
"Content-Type": "application/json",
|
|
554
|
+
// MarginFront authenticates with x-api-key, NOT Bearer/Authorization.
|
|
555
|
+
"x-api-key": apiKey,
|
|
556
|
+
// This User-Agent is what lets the server tag these events as source='ccc'
|
|
557
|
+
// instead of the generic 'api' fallback. Without it, PostHog dashboards
|
|
558
|
+
// can't distinguish "developer paying for AI tokens via CCC" traffic from
|
|
559
|
+
// raw API clients - every CCC event would be mislabelled as source='api'.
|
|
560
|
+
"User-Agent": `@marginfront/code-cost-clarity/${CCC_PACKAGE_VERSION}`
|
|
561
|
+
},
|
|
562
|
+
body: JSON.stringify(requestBody)
|
|
563
|
+
});
|
|
564
|
+
} catch (networkError) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
reason: "network",
|
|
568
|
+
message: networkError.message
|
|
569
|
+
};
|
|
684
570
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
571
|
+
const responseText = await response.text();
|
|
572
|
+
if (!response.ok) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
reason: "http",
|
|
576
|
+
status: response.status,
|
|
577
|
+
body: responseText
|
|
578
|
+
};
|
|
688
579
|
}
|
|
689
|
-
let
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
`Removed ${removed.length} MarginFront telemetry setting(s) from ${settingsPath} (backup at ${backupPath}).`
|
|
695
|
-
);
|
|
696
|
-
} else {
|
|
697
|
-
log(
|
|
698
|
-
`Nothing to remove from ${settingsPath} - the telemetry keys were already gone or you'd changed them (left untouched).`
|
|
699
|
-
);
|
|
580
|
+
let parsed = null;
|
|
581
|
+
try {
|
|
582
|
+
parsed = JSON.parse(responseText);
|
|
583
|
+
} catch {
|
|
584
|
+
parsed = null;
|
|
700
585
|
}
|
|
701
|
-
|
|
702
|
-
return { removed, kept, backupPath };
|
|
586
|
+
return { ok: true, parsed, body: responseText };
|
|
703
587
|
}
|
|
704
|
-
function
|
|
705
|
-
|
|
588
|
+
function successRowsFrom(parsed) {
|
|
589
|
+
const rows = parsed?.results?.success;
|
|
590
|
+
return Array.isArray(rows) ? rows : [];
|
|
591
|
+
}
|
|
592
|
+
function readWatchCursor(cursorPath) {
|
|
593
|
+
if (!cursorPath || !existsSync(cursorPath)) return 0;
|
|
706
594
|
try {
|
|
707
|
-
|
|
708
|
-
return
|
|
709
|
-
} catch
|
|
710
|
-
return
|
|
595
|
+
const parsed = parseInt(readFileSync(cursorPath, "utf8").trim(), 10);
|
|
596
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
597
|
+
} catch {
|
|
598
|
+
return 0;
|
|
711
599
|
}
|
|
712
600
|
}
|
|
713
|
-
function
|
|
714
|
-
if (!
|
|
601
|
+
function writeWatchCursor(cursorPath, byteOffset) {
|
|
602
|
+
if (!cursorPath) return;
|
|
715
603
|
try {
|
|
716
|
-
|
|
717
|
-
return Number.isInteger(pid) ? pid : null;
|
|
604
|
+
writeFileSync(cursorPath, String(byteOffset));
|
|
718
605
|
} catch {
|
|
719
|
-
return null;
|
|
720
606
|
}
|
|
721
607
|
}
|
|
722
|
-
function
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
608
|
+
function readNewCompleteLines(filePath, fromByte) {
|
|
609
|
+
if (!existsSync(filePath)) return { lines: [], nextCursor: fromByte };
|
|
610
|
+
let size;
|
|
611
|
+
try {
|
|
612
|
+
size = statSync(filePath).size;
|
|
613
|
+
} catch {
|
|
614
|
+
return { lines: [], nextCursor: fromByte };
|
|
727
615
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
616
|
+
let start = fromByte;
|
|
617
|
+
if (size < start) start = 0;
|
|
618
|
+
if (size === start) return { lines: [], nextCursor: start };
|
|
619
|
+
const length = size - start;
|
|
620
|
+
const buffer = Buffer.alloc(length);
|
|
621
|
+
let bytesRead = 0;
|
|
622
|
+
let fd;
|
|
623
|
+
try {
|
|
624
|
+
fd = openSync(filePath, "r");
|
|
625
|
+
bytesRead = readSync(fd, buffer, 0, length, start);
|
|
626
|
+
} catch {
|
|
627
|
+
return { lines: [], nextCursor: fromByte };
|
|
628
|
+
} finally {
|
|
629
|
+
if (fd !== void 0) closeSync(fd);
|
|
732
630
|
}
|
|
733
|
-
const
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
stdio: ["ignore", logFd, logFd]
|
|
738
|
-
});
|
|
739
|
-
child.unref();
|
|
740
|
-
writeFileSync(COLLECTOR_PID_PATH, String(child.pid));
|
|
741
|
-
log(`Started collector (pid ${child.pid}); logs at ${COLLECTOR_LOG_PATH}.`);
|
|
742
|
-
return { pid: child.pid, startedByUs: true };
|
|
743
|
-
}
|
|
744
|
-
function stopCollector({
|
|
745
|
-
log = console.log
|
|
746
|
-
} = {}) {
|
|
747
|
-
const pid = readCollectorPid();
|
|
748
|
-
if (!isPidAlive(pid)) {
|
|
749
|
-
if (existsSync(COLLECTOR_PID_PATH))
|
|
750
|
-
rmSync(COLLECTOR_PID_PATH, { force: true });
|
|
751
|
-
log("No running collector found.");
|
|
752
|
-
return false;
|
|
631
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
632
|
+
const lastNewline = chunk.lastIndexOf(10);
|
|
633
|
+
if (lastNewline === -1) {
|
|
634
|
+
return { lines: [], nextCursor: start };
|
|
753
635
|
}
|
|
636
|
+
const completeText = chunk.subarray(0, lastNewline + 1).toString("utf8");
|
|
637
|
+
const nextCursor = start + lastNewline + 1;
|
|
638
|
+
const parts = completeText.split("\n");
|
|
639
|
+
parts.pop();
|
|
640
|
+
return { lines: parts, nextCursor };
|
|
641
|
+
}
|
|
642
|
+
var MAX_QUEUE_RECORDS = 1e4;
|
|
643
|
+
var MAX_RECORDS_PER_DRAIN = 100;
|
|
644
|
+
var QUEUE_BACKOFF_START_MS = 2e3;
|
|
645
|
+
var QUEUE_BACKOFF_MAX_MS = 6e4;
|
|
646
|
+
var COLLECTOR_REVIVE_BACKOFF_MS = 3e4;
|
|
647
|
+
function maybeReviveCollector(deps, nextReviveAllowedAt, backoffMs) {
|
|
648
|
+
if (deps.isCollectorAlive()) return nextReviveAllowedAt;
|
|
649
|
+
const now = deps.now();
|
|
650
|
+
if (now < nextReviveAllowedAt) return nextReviveAllowedAt;
|
|
651
|
+
const log = deps.log ?? console.error;
|
|
652
|
+
const updatedReviveAllowedAt = now + backoffMs;
|
|
653
|
+
log("collector was down, restarted it");
|
|
754
654
|
try {
|
|
755
|
-
|
|
756
|
-
log(`Stopped collector (pid ${pid}).`);
|
|
655
|
+
deps.reviveCollector();
|
|
757
656
|
} catch (err) {
|
|
758
|
-
log(`
|
|
657
|
+
log(`collector revive attempt failed: ${err.message}`);
|
|
759
658
|
}
|
|
760
|
-
|
|
761
|
-
return true;
|
|
762
|
-
}
|
|
763
|
-
var PLIST_LABEL = "ai.marginfront.ccc";
|
|
764
|
-
var PLIST_PATH = join(
|
|
765
|
-
homedir(),
|
|
766
|
-
"Library",
|
|
767
|
-
"LaunchAgents",
|
|
768
|
-
PLIST_LABEL + ".plist"
|
|
769
|
-
);
|
|
770
|
-
var DAEMON_CLI_PATH = join(CONFIG_DIR, "cli.js");
|
|
771
|
-
var DAEMON_STDOUT_LOG_PATH = join(CONFIG_DIR, "forwarder.out.log");
|
|
772
|
-
var DAEMON_STDERR_LOG_PATH = join(CONFIG_DIR, "forwarder.err.log");
|
|
773
|
-
function defaultLaunchctlRunner(args) {
|
|
774
|
-
const result = spawnSync("launchctl", args, { encoding: "utf8" });
|
|
775
|
-
return {
|
|
776
|
-
// A spawn error (e.g. launchctl missing on a non-mac) leaves status null; we
|
|
777
|
-
// treat that as a failure so callers don't read it as "succeeded."
|
|
778
|
-
status: typeof result.status === "number" ? result.status : 1,
|
|
779
|
-
stdout: result.stdout ?? "",
|
|
780
|
-
stderr: result.stderr ?? ""
|
|
781
|
-
};
|
|
659
|
+
return updatedReviveAllowedAt;
|
|
782
660
|
}
|
|
783
|
-
function
|
|
784
|
-
|
|
785
|
-
|
|
661
|
+
function isRetryablePostResult(result) {
|
|
662
|
+
if (result.ok) return false;
|
|
663
|
+
if (result.reason === "network") return true;
|
|
664
|
+
if (result.reason === "no-key") return false;
|
|
665
|
+
if (result.reason === "http") {
|
|
666
|
+
return result.status === 429 || result.status >= 500;
|
|
786
667
|
}
|
|
668
|
+
return false;
|
|
787
669
|
}
|
|
788
|
-
function
|
|
789
|
-
|
|
790
|
-
|
|
670
|
+
function readQueue(queuePath) {
|
|
671
|
+
if (!queuePath || !existsSync(queuePath)) return [];
|
|
672
|
+
let text;
|
|
673
|
+
try {
|
|
674
|
+
text = readFileSync(queuePath, "utf8");
|
|
675
|
+
} catch {
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
const out = [];
|
|
679
|
+
for (const line of text.split("\n")) {
|
|
680
|
+
const trimmed = line.trim();
|
|
681
|
+
if (!trimmed) continue;
|
|
682
|
+
try {
|
|
683
|
+
out.push(JSON.parse(trimmed));
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
791
686
|
}
|
|
687
|
+
return out;
|
|
792
688
|
}
|
|
793
|
-
function
|
|
794
|
-
|
|
689
|
+
function appendToQueue(queuePath, records) {
|
|
690
|
+
if (!queuePath || records.length === 0) return;
|
|
691
|
+
const payload = records.map((r) => JSON.stringify(r) + "\n").join("");
|
|
692
|
+
try {
|
|
693
|
+
appendFileSync(queuePath, payload);
|
|
694
|
+
} catch {
|
|
695
|
+
}
|
|
795
696
|
}
|
|
796
|
-
function
|
|
797
|
-
|
|
697
|
+
function writeQueue(queuePath, records) {
|
|
698
|
+
if (!queuePath) return;
|
|
699
|
+
try {
|
|
700
|
+
if (records.length === 0) {
|
|
701
|
+
if (existsSync(queuePath)) rmSync(queuePath, { force: true });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
writeFileSync(
|
|
705
|
+
queuePath,
|
|
706
|
+
records.map((r) => JSON.stringify(r) + "\n").join("")
|
|
707
|
+
);
|
|
708
|
+
} catch {
|
|
709
|
+
}
|
|
798
710
|
}
|
|
799
|
-
function
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
711
|
+
function watchAndForward(filePath, options = {}) {
|
|
712
|
+
const foldCache = options.foldCache === true;
|
|
713
|
+
const fallbackCustomerId = options.fallbackCustomerId;
|
|
714
|
+
const cursorPath = options.cursorPath;
|
|
715
|
+
const pidFilePath = options.pidFilePath;
|
|
716
|
+
const developerOverride = resolveDeveloperOverride(process.env);
|
|
717
|
+
if (!process.env.MARGINFRONT_API_KEY) {
|
|
718
|
+
console.error(
|
|
719
|
+
"MARGINFRONT_API_KEY is not set. Set it in your shell before watching: export MARGINFRONT_API_KEY=..."
|
|
720
|
+
);
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
if (pidFilePath) {
|
|
724
|
+
try {
|
|
725
|
+
writeFileSync(pidFilePath, String(process.pid));
|
|
726
|
+
} catch {
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
console.log(
|
|
730
|
+
`Watching ${filePath} for live Claude Code + Codex usage` + (foldCache ? " (fold-cache: cache tokens rolled into input)" : "") + "\u2026 Ctrl-C to stop."
|
|
731
|
+
);
|
|
732
|
+
let cursorBytes = readWatchCursor(cursorPath);
|
|
733
|
+
let running = false;
|
|
734
|
+
const queuePath = options.queuePath;
|
|
735
|
+
let queuedCount = readQueue(queuePath).length;
|
|
736
|
+
let queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
737
|
+
let nextQueueRetryAt = 0;
|
|
738
|
+
const hhmmss = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
739
|
+
let nextCollectorReviveAt = 0;
|
|
740
|
+
const collectorHealDeps = {
|
|
741
|
+
isCollectorAlive: () => isPidAlive(readCollectorPid()),
|
|
742
|
+
reviveCollector: () => {
|
|
743
|
+
startCollector();
|
|
744
|
+
},
|
|
745
|
+
now: () => Date.now(),
|
|
746
|
+
log: (message) => console.error(`[${hhmmss()}] ${message}`)
|
|
747
|
+
};
|
|
748
|
+
function logSuccess(body, result, stamp) {
|
|
749
|
+
if (!result.ok) return;
|
|
750
|
+
for (const rec of body.records) {
|
|
751
|
+
const row = successRowsFrom(result.parsed).find(
|
|
752
|
+
(r) => r?.customerExternalId === rec.customerExternalId
|
|
753
|
+
);
|
|
754
|
+
const cost = row?.totalCostUsd != null ? `$${row.totalCostUsd}` : "$?";
|
|
755
|
+
const eventId = row?.eventId ? ` event=${row.eventId}` : "";
|
|
756
|
+
const reference = "source" in rec.metadata ? `src=${rec.metadata.source}` : `cc=$${rec.metadata.claudeCodeCostUsd ?? "?"}`;
|
|
757
|
+
const volume = rec.quantity !== void 0 ? `qty=${rec.quantity} tool=${"toolName" in rec.metadata ? rec.metadata.toolName : "?"}` : `in=${rec.inputTokens} out=${rec.outputTokens}`;
|
|
758
|
+
console.log(
|
|
759
|
+
`[${stamp}] recorded ${rec.customerExternalId} \xB7 ${volume} \xB7 server=${cost} \xB7 ${reference}${eventId}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function logFailure(result, stamp) {
|
|
764
|
+
if (result.ok) return;
|
|
765
|
+
if (result.reason === "http") {
|
|
766
|
+
console.error(
|
|
767
|
+
`[${stamp}] MarginFront HTTP ${result.status}: ${result.body}`
|
|
768
|
+
);
|
|
769
|
+
} else if (result.reason === "network") {
|
|
770
|
+
console.error(
|
|
771
|
+
`[${stamp}] could not reach MarginFront: ${result.message}`
|
|
772
|
+
);
|
|
773
|
+
} else {
|
|
774
|
+
console.error(`[${stamp}] send failed (${result.reason}).`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function enqueueFailed(records, stamp) {
|
|
778
|
+
const room = Math.max(0, MAX_QUEUE_RECORDS - queuedCount);
|
|
779
|
+
const toQueue = records.slice(0, room);
|
|
780
|
+
const shed = records.length - toQueue.length;
|
|
781
|
+
if (toQueue.length > 0) {
|
|
782
|
+
appendToQueue(queuePath, toQueue);
|
|
783
|
+
queuedCount += toQueue.length;
|
|
784
|
+
console.error(
|
|
785
|
+
`[${stamp}] send failed - queued ${toQueue.length} record(s) for retry (${queuedCount} now in queue).`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (shed > 0) {
|
|
789
|
+
console.error(
|
|
790
|
+
`[${stamp}] QUEUE FULL (cap ${MAX_QUEUE_RECORDS}): shed ${shed} record(s) - these turns are LOST. The ingest endpoint has been unreachable for a long time; check connectivity.`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
nextQueueRetryAt = Date.now() + queueBackoffMs;
|
|
794
|
+
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
795
|
+
}
|
|
796
|
+
async function drainQueue() {
|
|
797
|
+
if (!queuePath || queuedCount === 0) return;
|
|
798
|
+
if (Date.now() < nextQueueRetryAt) return;
|
|
799
|
+
const queued = readQueue(queuePath);
|
|
800
|
+
queuedCount = queued.length;
|
|
801
|
+
if (queued.length === 0) return;
|
|
802
|
+
const batch = queued.slice(0, MAX_RECORDS_PER_DRAIN);
|
|
803
|
+
const result = await postToMarginFront({ records: batch });
|
|
804
|
+
const stamp = hhmmss();
|
|
805
|
+
if (result.ok) {
|
|
806
|
+
const remaining = queued.slice(batch.length);
|
|
807
|
+
writeQueue(queuePath, remaining);
|
|
808
|
+
queuedCount = remaining.length;
|
|
809
|
+
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
810
|
+
nextQueueRetryAt = 0;
|
|
811
|
+
console.log(
|
|
812
|
+
`[${stamp}] queue: ${batch.length} retried record(s) landed \xB7 ${remaining.length} still queued.`
|
|
813
|
+
);
|
|
814
|
+
} else if (isRetryablePostResult(result)) {
|
|
815
|
+
logFailure(result, stamp);
|
|
816
|
+
const waitMs = queueBackoffMs;
|
|
817
|
+
nextQueueRetryAt = Date.now() + waitMs;
|
|
818
|
+
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
819
|
+
console.error(
|
|
820
|
+
`[${stamp}] queue: retry failed, ${queued.length} record(s) still queued (next attempt in ~${Math.round(waitMs / 1e3)}s).`
|
|
821
|
+
);
|
|
822
|
+
} else {
|
|
823
|
+
const remaining = queued.slice(batch.length);
|
|
824
|
+
writeQueue(queuePath, remaining);
|
|
825
|
+
queuedCount = remaining.length;
|
|
826
|
+
console.error(
|
|
827
|
+
`[${stamp}] queue: dropping ${batch.length} record(s) - terminal failure (server rejected them, retrying can't help).`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function processNewLines() {
|
|
832
|
+
if (running) return;
|
|
833
|
+
running = true;
|
|
834
|
+
try {
|
|
835
|
+
await drainQueue();
|
|
836
|
+
const { lines, nextCursor } = readNewCompleteLines(filePath, cursorBytes);
|
|
837
|
+
for (const rawLine of lines) {
|
|
838
|
+
const line = rawLine.trim();
|
|
839
|
+
if (!line) continue;
|
|
840
|
+
let parsed;
|
|
841
|
+
try {
|
|
842
|
+
parsed = JSON.parse(line);
|
|
843
|
+
} catch {
|
|
844
|
+
console.error("(skipped one telemetry line that wasn't valid JSON)");
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const body = buildRequestBodyForLine(parsed, {
|
|
848
|
+
foldCache,
|
|
849
|
+
fallbackCustomerId,
|
|
850
|
+
developerOverride,
|
|
851
|
+
rawSourceLine: line
|
|
852
|
+
});
|
|
853
|
+
if (!body.records.length) continue;
|
|
854
|
+
const result = await postToMarginFront(body);
|
|
855
|
+
const stamp = hhmmss();
|
|
856
|
+
if (!result.ok) {
|
|
857
|
+
logFailure(result, stamp);
|
|
858
|
+
if (queuePath && isRetryablePostResult(result)) {
|
|
859
|
+
enqueueFailed(body.records, stamp);
|
|
860
|
+
} else if (queuePath) {
|
|
861
|
+
console.error(
|
|
862
|
+
`[${stamp}] dropping ${body.records.length} record(s): terminal send failure (not retryable).`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
logSuccess(body, result, stamp);
|
|
868
|
+
if (queuedCount > 0) {
|
|
869
|
+
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
870
|
+
nextQueueRetryAt = 0;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (nextCursor !== cursorBytes) {
|
|
874
|
+
cursorBytes = nextCursor;
|
|
875
|
+
writeWatchCursor(cursorPath, cursorBytes);
|
|
876
|
+
}
|
|
877
|
+
} finally {
|
|
878
|
+
running = false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
processNewLines().catch(
|
|
882
|
+
(e) => console.error(`watch error: ${e.message}`)
|
|
883
|
+
);
|
|
884
|
+
const timer = setInterval(() => {
|
|
885
|
+
nextCollectorReviveAt = maybeReviveCollector(
|
|
886
|
+
collectorHealDeps,
|
|
887
|
+
nextCollectorReviveAt,
|
|
888
|
+
COLLECTOR_REVIVE_BACKOFF_MS
|
|
889
|
+
);
|
|
890
|
+
processNewLines().catch(
|
|
891
|
+
(e) => console.error(`watch error: ${e.message}`)
|
|
892
|
+
);
|
|
893
|
+
}, WATCH_POLL_MS);
|
|
894
|
+
return function stop() {
|
|
895
|
+
clearInterval(timer);
|
|
896
|
+
if (pidFilePath) {
|
|
897
|
+
try {
|
|
898
|
+
rmSync(pidFilePath, { force: true });
|
|
899
|
+
} catch {
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/connector.ts
|
|
906
|
+
var CONFIG_DIR = join(homedir(), ".marginfront-ccc");
|
|
907
|
+
var ENV_PATH = join(CONFIG_DIR, ".env");
|
|
908
|
+
var COLLECTOR_CONFIG_PATH = join(CONFIG_DIR, "otel-collector.yaml");
|
|
909
|
+
var COLLECTOR_BIN_PATH = join(CONFIG_DIR, "otelcol-contrib");
|
|
910
|
+
var LIVE_JSONL_PATH = join(CONFIG_DIR, "live-otlp.jsonl");
|
|
911
|
+
var COLLECTOR_PID_PATH = join(CONFIG_DIR, "collector.pid");
|
|
912
|
+
var COLLECTOR_LOG_PATH = join(CONFIG_DIR, "collector.log");
|
|
913
|
+
var FORWARDER_CURSOR_PATH = join(CONFIG_DIR, "forwarder.cursor");
|
|
914
|
+
var FORWARDER_QUEUE_PATH = join(CONFIG_DIR, "forwarder.queue.jsonl");
|
|
915
|
+
var FORWARDER_PID_PATH = join(CONFIG_DIR, "forwarder.pid");
|
|
916
|
+
var VERSION_PATTERN = /^v\d+\.\d+\.\d+$/;
|
|
917
|
+
function resolveOtelcolVersion(override = process2.env.CCC_OTELCOL_VERSION) {
|
|
918
|
+
const DEFAULT_VERSION = "v0.154.0";
|
|
919
|
+
if (!override) return DEFAULT_VERSION;
|
|
920
|
+
if (!VERSION_PATTERN.test(override)) {
|
|
921
|
+
throw new Error(
|
|
922
|
+
`Invalid CCC_OTELCOL_VERSION "${override}".
|
|
923
|
+
What happened: you set the collector version override, but it isn't a valid version number.
|
|
924
|
+
Why this is strict: that value goes into a download URL and a filename, so a malformed one could point the installer somewhere unsafe. We refuse rather than guess.
|
|
925
|
+
Fix: use the form vMAJOR.MINOR.PATCH (for example CCC_OTELCOL_VERSION=v0.154.0), or unset it to use the built-in pinned version.`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
return override;
|
|
929
|
+
}
|
|
930
|
+
var OTELCOL_VERSION = resolveOtelcolVersion();
|
|
931
|
+
var COLLECTOR_CHECKSUMS = {
|
|
932
|
+
"otelcol-contrib_0.154.0_darwin_amd64.tar.gz": "14f7f825a7ad7ee0799947ff70a42b9a5528a9c1411ab45f8fcc4caeb9346f7b",
|
|
933
|
+
"otelcol-contrib_0.154.0_darwin_arm64.tar.gz": "de3af70ef0b80af213911cc9ba8daf553348dd22ed1fb15db561d207fc2cb3d9",
|
|
934
|
+
"otelcol-contrib_0.154.0_linux_amd64.tar.gz": "f0fe6e7b1d81936d4e5a3aad7a678f3fc2f8ada2a9f8f37f37542813c12ed322",
|
|
935
|
+
"otelcol-contrib_0.154.0_linux_arm64.tar.gz": "52310bfcb5a952c072e8abff7f0f2940ee38cea36752a4a4e1a4e21c2088c3f9"
|
|
936
|
+
};
|
|
937
|
+
function verifyTarballChecksum(tarballPath, expectedSha256) {
|
|
938
|
+
const actual = createHash2("sha256").update(readFileSync2(tarballPath)).digest("hex").toLowerCase();
|
|
939
|
+
return actual === expectedSha256.toLowerCase();
|
|
940
|
+
}
|
|
941
|
+
function renderEnvFile() {
|
|
942
|
+
return `# ============================================================
|
|
943
|
+
# Claude Code -> MarginFront connector: environment settings
|
|
944
|
+
# ============================================================
|
|
945
|
+
# MAIN JOB of this file: hold your MARGINFRONT_API_KEY (below) so the background
|
|
946
|
+
# meter can read it - fill that in (init usually does it for you).
|
|
947
|
+
#
|
|
948
|
+
# As of v0.5.0 you do NOT need to 'source' this before running Claude Code:
|
|
949
|
+
# 'init' wires the telemetry settings into ~/.claude/settings.json for you, so
|
|
950
|
+
# every 'claude'/'codex' session (and the desktop apps) broadcast on their own.
|
|
951
|
+
# Sourcing this is only useful for the manual, foreground 'run' path.
|
|
952
|
+
#
|
|
953
|
+
# This file stays on your machine; it is never published or committed.
|
|
954
|
+
# ============================================================
|
|
955
|
+
|
|
956
|
+
# Turns on Claude Code's usage broadcasting. Leave as 1.
|
|
957
|
+
CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
958
|
+
|
|
959
|
+
# Send usage as OpenTelemetry metrics. Leave as otlp.
|
|
960
|
+
OTEL_METRICS_EXPORTER=otlp
|
|
961
|
+
|
|
962
|
+
# Send Claude Code's EVENTS (the per-tool-call records) too. Leave as otlp.
|
|
963
|
+
# Tokens come in as metrics (above); a paid tool call (Google Maps, Browserbase,
|
|
964
|
+
# a paid MCP server) only shows up on this EVENT stream - without this line Claude
|
|
965
|
+
# Code broadcasts no tool activity and tool spend can't be tracked (LOWAI-478).
|
|
966
|
+
OTEL_LOGS_EXPORTER=otlp
|
|
967
|
+
|
|
968
|
+
# Use HTTP/protobuf. Verified working; gRPC on 4317 silently failed in testing.
|
|
969
|
+
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
970
|
+
|
|
971
|
+
# Where the local collector listens. Must match the collector YAML.
|
|
972
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
|
973
|
+
|
|
974
|
+
# How often Claude Code flushes usage, in milliseconds.
|
|
975
|
+
# 300000 = 5 minutes - batches usage so you get roughly one event per turn
|
|
976
|
+
# without a long wait. Raise to 600000 (10 min) to batch harder; lower to
|
|
977
|
+
# 60000 (1 min) or 5000 (5 s) for a more live drip.
|
|
978
|
+
OTEL_METRIC_EXPORT_INTERVAL=300000
|
|
979
|
+
|
|
980
|
+
# Tool-call costs (LOWAI-478) need NOTHING configured here. Every tool your agents
|
|
981
|
+
# fire (other than free built-ins like Read/Bash) is forwarded automatically;
|
|
982
|
+
# MarginFront's pricing catalog decides what's billable and at what rate. Add price
|
|
983
|
+
# rows for your paid tools in MarginFront - there's no client-side list to maintain.
|
|
984
|
+
|
|
985
|
+
# WHO this machine's AI cost belongs to (per-developer attribution).
|
|
986
|
+
# WHY THIS EXISTS, plainly: lots of teams share ONE Claude/Codex login, so every
|
|
987
|
+
# developer's usage carries the SAME login email and all the cost piles onto one
|
|
988
|
+
# person. CCC runs locally on each laptop, so naming the developer here splits that
|
|
989
|
+
# shared-login cost back out per developer. This is INTERNAL cost visibility only -
|
|
990
|
+
# it does NOT change anyone's bill.
|
|
991
|
+
#
|
|
992
|
+
# HOW IT'S USED at send time (precedence, highest wins):
|
|
993
|
+
# 1. This CCC_DEVELOPER_EMAIL, when set - it wins for EVERY record from this machine.
|
|
994
|
+
# 2. Otherwise the email the coding-agent login reports (user.email).
|
|
995
|
+
# 3. Otherwise a clearly-labeled no-identity placeholder.
|
|
996
|
+
# Leave it BLANK to keep today's behavior (auto-detect from the login). init fills
|
|
997
|
+
# this in for you, defaulting to your global git user.email - change it anytime.
|
|
998
|
+
CCC_DEVELOPER_EMAIL=
|
|
999
|
+
|
|
1000
|
+
# YOUR MarginFront SECRET key (mf_sk_*). Create or copy one at:
|
|
1001
|
+
# app.marginfront.com -> Build -> API Keys -> "Create Key Pair"
|
|
1002
|
+
# (https://app.marginfront.com/developer-zone/api-keys)
|
|
1003
|
+
# Use the SECRET key (mf_sk_*) - a publishable key (mf_pk_*) is rejected on send.
|
|
1004
|
+
# The forwarder reads this from the environment only; it is never hardcoded.
|
|
1005
|
+
# Leave blank to run in no-identity preview mode (see the README).
|
|
1006
|
+
MARGINFRONT_API_KEY=
|
|
827
1007
|
`;
|
|
828
1008
|
}
|
|
829
|
-
function
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1009
|
+
function renderCollectorYaml(jsonlPath = LIVE_JSONL_PATH) {
|
|
1010
|
+
return `# OpenTelemetry Collector config - Claude Code -> MarginFront
|
|
1011
|
+
# ---------------------------------------------------------------------------
|
|
1012
|
+
# WHAT THIS DOES, plainly: it's the "catcher." Claude Code broadcasts its token
|
|
1013
|
+
# usage here; this collector batches what it hears and writes it to a file,
|
|
1014
|
+
# which the forwarder tails and turns into MarginFront usage rows.
|
|
1015
|
+
# ---------------------------------------------------------------------------
|
|
1016
|
+
|
|
1017
|
+
receivers:
|
|
1018
|
+
# The door Claude Code knocks on. It listens for OpenTelemetry (OTLP) on the
|
|
1019
|
+
# two standard local ports. We point Claude Code at HTTP/protobuf on 4318 -
|
|
1020
|
+
# gRPC on 4317 silently failed to connect from Claude Code 2.1.185 in testing,
|
|
1021
|
+
# so 4318 is the one that actually works. Bound to localhost only.
|
|
1022
|
+
otlp:
|
|
1023
|
+
protocols:
|
|
1024
|
+
grpc:
|
|
1025
|
+
endpoint: 127.0.0.1:4317
|
|
1026
|
+
http:
|
|
1027
|
+
endpoint: 127.0.0.1:4318
|
|
1028
|
+
|
|
1029
|
+
processors:
|
|
1030
|
+
# THE NO-DOUBLE-COUNT PROCESSOR. Claude Code emits CUMULATIVE counters (each
|
|
1031
|
+
# export is the running total since the session started), and it ignores the
|
|
1032
|
+
# "give me deltas" env var. Without this, the forwarder would record the
|
|
1033
|
+
# running total every export - re-counting everything before it. This turns
|
|
1034
|
+
# each cumulative total into the increment since the last export.
|
|
1035
|
+
cumulativetodelta: {}
|
|
1036
|
+
|
|
1037
|
+
# Group what arrives into small batches and flush quickly, so the live view
|
|
1038
|
+
# feels near-real-time rather than trickling one datapoint at a time.
|
|
1039
|
+
batch:
|
|
1040
|
+
timeout: 1s
|
|
1041
|
+
|
|
1042
|
+
exporters:
|
|
1043
|
+
# Write each batch as one line of OTLP/JSON (JSON Lines). The forwarder
|
|
1044
|
+
# watches this file and records each new line. Both pipelines below share this
|
|
1045
|
+
# one exporter, so Claude Code's metrics and Codex's logs land in the SAME file
|
|
1046
|
+
# (each as its own line) and the one forwarder reads both.
|
|
1047
|
+
file:
|
|
1048
|
+
path: ${jsonlPath}
|
|
1049
|
+
|
|
1050
|
+
service:
|
|
1051
|
+
telemetry:
|
|
1052
|
+
logs:
|
|
1053
|
+
# The collector's OWN log verbosity (how chatty otelcol itself is). This is
|
|
1054
|
+
# NOT a data pipeline - it does not catch Codex's usage logs. The pipeline
|
|
1055
|
+
# that does is service.pipelines.logs below. Keep this quiet.
|
|
1056
|
+
level: warn
|
|
1057
|
+
pipelines:
|
|
1058
|
+
# Claude Code source: token + cost COUNTERS arrive as metrics.
|
|
1059
|
+
# Receive -> de-dupe (cumulative->delta) -> batch -> file.
|
|
1060
|
+
metrics:
|
|
1061
|
+
receivers: [otlp]
|
|
1062
|
+
processors: [cumulativetodelta, batch]
|
|
1063
|
+
exporters: [file]
|
|
1064
|
+
# Codex source (LOWAI-463): Codex reports usage on the LOG stream, not as
|
|
1065
|
+
# metrics, so it needs its own pipeline. No cumulativetodelta here - that's a
|
|
1066
|
+
# METRICS processor and doesn't apply to logs; each Codex log record is one
|
|
1067
|
+
# turn's usage on its own. Receive -> batch -> file (the same file).
|
|
1068
|
+
logs:
|
|
1069
|
+
receivers: [otlp]
|
|
1070
|
+
processors: [batch]
|
|
1071
|
+
exporters: [file]
|
|
1072
|
+
`;
|
|
1073
|
+
}
|
|
1074
|
+
var CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
1075
|
+
var CODEX_CCC_BEGIN_MARKER = "# >>> MarginFront code-cost-clarity - auto-added; `npx @marginfront/code-cost-clarity uninstall` removes this block >>>";
|
|
1076
|
+
var CODEX_CCC_END_MARKER = "# <<< MarginFront code-cost-clarity - end of auto-added block <<<";
|
|
1077
|
+
var CODEX_OTEL_TOML_BLOCK = [
|
|
1078
|
+
"[otel]",
|
|
1079
|
+
'exporter = { otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "binary" } }'
|
|
1080
|
+
].join("\n");
|
|
1081
|
+
function renderCodexCccBlock() {
|
|
1082
|
+
return [
|
|
1083
|
+
CODEX_CCC_BEGIN_MARKER,
|
|
1084
|
+
CODEX_OTEL_TOML_BLOCK,
|
|
1085
|
+
CODEX_CCC_END_MARKER
|
|
1086
|
+
].join("\n");
|
|
1087
|
+
}
|
|
1088
|
+
function renderCodexConfigInstructions() {
|
|
1089
|
+
return [
|
|
1090
|
+
"Optional - also capture Codex (in addition to, or instead of, Claude Code):",
|
|
1091
|
+
"",
|
|
1092
|
+
" Codex reports its usage to the SAME local collector, so there's nothing",
|
|
1093
|
+
" extra to install. Add this block to your ~/.codex/config.toml:",
|
|
1094
|
+
"",
|
|
1095
|
+
// Single-sourced from CODEX_OTEL_TOML_BLOCK above, indented for the printout,
|
|
1096
|
+
// so the pasted block always matches what the auto-writer would write.
|
|
1097
|
+
...CODEX_OTEL_TOML_BLOCK.split("\n").map((line) => " " + line),
|
|
1098
|
+
"",
|
|
1099
|
+
" Then run Codex normally. `run` already watches both sources - Claude Code,",
|
|
1100
|
+
" Codex, or both - from the one collector file; there's no extra flag.",
|
|
1101
|
+
"",
|
|
1102
|
+
" Note: exact [otel] key names can vary by Codex version, and whether your",
|
|
1103
|
+
" developer email shows up depends on how you sign in (org API key vs ChatGPT",
|
|
1104
|
+
" login). If the email doesn't surface, usage still lands under the no-identity",
|
|
1105
|
+
" placeholder. See the README's Codex section for the full details."
|
|
1106
|
+
].join("\n");
|
|
1107
|
+
}
|
|
1108
|
+
function lineDeclaresOtelTable(rawLine) {
|
|
1109
|
+
const beforeComment = rawLine.split("#")[0].trim();
|
|
1110
|
+
if (!beforeComment) return false;
|
|
1111
|
+
let firstToken;
|
|
1112
|
+
if (beforeComment.startsWith("[")) {
|
|
1113
|
+
const inner = beforeComment.replace(/^\[+/, "").trim();
|
|
1114
|
+
const path = inner.split("]")[0].trim();
|
|
1115
|
+
firstToken = path.split(".")[0].trim();
|
|
1116
|
+
} else {
|
|
1117
|
+
firstToken = beforeComment.split(/[.=\s]/)[0].trim();
|
|
1118
|
+
}
|
|
1119
|
+
firstToken = firstToken.replace(/^["']|["']$/g, "");
|
|
1120
|
+
return firstToken === "otel";
|
|
1121
|
+
}
|
|
1122
|
+
function codexConfigHasOtelTable(content) {
|
|
1123
|
+
return content.split(/\r?\n/).some(lineDeclaresOtelTable);
|
|
1124
|
+
}
|
|
1125
|
+
function codexConfigHasCccBlock(configPath = CODEX_CONFIG_PATH) {
|
|
1126
|
+
if (!existsSync2(configPath)) return false;
|
|
1127
|
+
try {
|
|
1128
|
+
return readFileSync2(configPath, "utf8").includes(CODEX_CCC_BEGIN_MARKER);
|
|
1129
|
+
} catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function writeCodexOtelConfig({
|
|
1134
|
+
configPath = CODEX_CONFIG_PATH,
|
|
1135
|
+
log = console.log
|
|
1136
|
+
} = {}) {
|
|
1137
|
+
if (!existsSync2(configPath)) {
|
|
1138
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
1139
|
+
writeFileSync2(configPath, renderCodexCccBlock() + "\n");
|
|
1140
|
+
log(
|
|
1141
|
+
`Created ${configPath} and wired Codex to the local collector for you.`
|
|
840
1142
|
);
|
|
1143
|
+
return { action: "created", backupPath: null };
|
|
841
1144
|
}
|
|
842
|
-
|
|
843
|
-
|
|
1145
|
+
const content = readFileSync2(configPath, "utf8");
|
|
1146
|
+
if (content.includes(CODEX_CCC_BEGIN_MARKER)) {
|
|
1147
|
+
log(`Codex is already wired up in ${configPath} - nothing to do.`);
|
|
1148
|
+
return { action: "skipped-existing", backupPath: null };
|
|
1149
|
+
}
|
|
1150
|
+
if (codexConfigHasOtelTable(content)) {
|
|
1151
|
+
log(
|
|
1152
|
+
`Left your existing [otel] settings in ${configPath} untouched - add the MarginFront block by hand if you want Codex captured too.`
|
|
1153
|
+
);
|
|
1154
|
+
return { action: "skipped-existing", backupPath: null };
|
|
1155
|
+
}
|
|
1156
|
+
const backupPath = `${configPath}.ccc-bak`;
|
|
1157
|
+
copyFileSync(configPath, backupPath);
|
|
1158
|
+
const separator = content.length > 0 ? "\n" : "";
|
|
1159
|
+
writeFileSync2(configPath, content + separator + renderCodexCccBlock() + "\n");
|
|
844
1160
|
log(
|
|
845
|
-
`
|
|
846
|
-
);
|
|
847
|
-
mkdirSync(dirname(plistPath), { recursive: true });
|
|
848
|
-
writeFileSync(
|
|
849
|
-
plistPath,
|
|
850
|
-
renderDaemonPlist({ nodePath, cliPath: daemonCliPath })
|
|
1161
|
+
`Added the MarginFront telemetry block to ${configPath} (backup at ${backupPath}).`
|
|
851
1162
|
);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1163
|
+
return { action: "appended", backupPath };
|
|
1164
|
+
}
|
|
1165
|
+
function removeCodexOtelConfig({
|
|
1166
|
+
configPath = CODEX_CONFIG_PATH,
|
|
1167
|
+
log = console.log
|
|
1168
|
+
} = {}) {
|
|
1169
|
+
if (!existsSync2(configPath)) {
|
|
858
1170
|
log(
|
|
859
|
-
|
|
1171
|
+
`Nothing to undo: ${configPath} doesn't exist (Codex was never wired up here).`
|
|
860
1172
|
);
|
|
861
|
-
|
|
862
|
-
reloaded = true;
|
|
1173
|
+
return { action: "missing", backupPath: null };
|
|
863
1174
|
}
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
`
|
|
870
|
-
` : "") + `Fix: run \`npx @marginfront/code-cost-clarity status\` to see the current state, or \`stop\` then \`start\` again. If it keeps failing, confirm you're on macOS and that ${plistPath} looks like valid XML.`
|
|
1175
|
+
const content = readFileSync2(configPath, "utf8");
|
|
1176
|
+
const beginIdx = content.indexOf(CODEX_CCC_BEGIN_MARKER);
|
|
1177
|
+
const endIdx = content.indexOf(CODEX_CCC_END_MARKER);
|
|
1178
|
+
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
1179
|
+
log(
|
|
1180
|
+
`Nothing to undo: ${configPath} has no MarginFront block (left every other setting untouched).`
|
|
871
1181
|
);
|
|
1182
|
+
return { action: "no-marker", backupPath: null };
|
|
872
1183
|
}
|
|
873
|
-
|
|
874
|
-
|
|
1184
|
+
let cutStart = beginIdx;
|
|
1185
|
+
let cutEnd = endIdx + CODEX_CCC_END_MARKER.length;
|
|
1186
|
+
if (content[cutEnd] === "\n") cutEnd += 1;
|
|
1187
|
+
if (content[cutStart - 1] === "\n") {
|
|
1188
|
+
cutStart -= 1;
|
|
1189
|
+
}
|
|
1190
|
+
const backupPath = existsSync2(`${configPath}.ccc-bak`) ? `${configPath}.ccc-bak` : null;
|
|
1191
|
+
writeFileSync2(configPath, content.slice(0, cutStart) + content.slice(cutEnd));
|
|
1192
|
+
log(
|
|
1193
|
+
`Removed the MarginFront block from ${configPath}` + (backupPath ? ` (your original is preserved at ${backupPath}).` : ".")
|
|
1194
|
+
);
|
|
1195
|
+
return { action: "removed", backupPath };
|
|
875
1196
|
}
|
|
876
|
-
function
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
const
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
log(`Removed the background-job definition at ${plistPath}.`);
|
|
1197
|
+
function resolveCollectorAsset(nodePlatform = platform(), nodeArch = arch(), version = OTELCOL_VERSION) {
|
|
1198
|
+
const osMap = { darwin: "darwin", linux: "linux" };
|
|
1199
|
+
const archMap = { arm64: "arm64", x64: "amd64" };
|
|
1200
|
+
const os = osMap[nodePlatform];
|
|
1201
|
+
const cpu = archMap[nodeArch];
|
|
1202
|
+
if (!os) {
|
|
1203
|
+
throw new Error(
|
|
1204
|
+
`Unsupported operating system "${nodePlatform}". The bundled collector supports macOS (darwin) and Linux. On Windows, run this under WSL.`
|
|
1205
|
+
);
|
|
886
1206
|
}
|
|
887
|
-
if (
|
|
888
|
-
|
|
889
|
-
|
|
1207
|
+
if (!cpu) {
|
|
1208
|
+
throw new Error(
|
|
1209
|
+
`Unsupported CPU architecture "${nodeArch}". The bundled collector supports arm64 and x64 (amd64).`
|
|
1210
|
+
);
|
|
890
1211
|
}
|
|
1212
|
+
const versionNoV = version.replace(/^v/, "");
|
|
1213
|
+
const asset = `otelcol-contrib_${versionNoV}_${os}_${cpu}.tar.gz`;
|
|
1214
|
+
const url = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/${version}/${asset}`;
|
|
1215
|
+
return { os, cpu, asset, url, version };
|
|
891
1216
|
}
|
|
892
|
-
function
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
return
|
|
1217
|
+
function ensureCollectorBinary({
|
|
1218
|
+
force = false,
|
|
1219
|
+
log = console.log
|
|
1220
|
+
} = {}) {
|
|
1221
|
+
if (existsSync2(COLLECTOR_BIN_PATH) && !force) {
|
|
1222
|
+
log(
|
|
1223
|
+
`Collector already present at ${COLLECTOR_BIN_PATH} - skipping download.`
|
|
1224
|
+
);
|
|
1225
|
+
return COLLECTOR_BIN_PATH;
|
|
901
1226
|
}
|
|
902
|
-
const
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
function queryDaemonStatus(options = {}) {
|
|
909
|
-
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
910
|
-
const uid = options.uid ?? currentUid();
|
|
911
|
-
return parseDaemonPrint(runLaunchctl(["print", `gui/${uid}/${PLIST_LABEL}`]));
|
|
912
|
-
}
|
|
913
|
-
function readLastLines(path, maxLines) {
|
|
914
|
-
if (!existsSync(path)) return [];
|
|
915
|
-
let text;
|
|
1227
|
+
const { asset, url, version } = resolveCollectorAsset();
|
|
1228
|
+
const tgzPath = join(tmpdir(), asset);
|
|
1229
|
+
log(
|
|
1230
|
+
`Downloading the collector (${asset}, ~360 MB) - this can take a minute\u2026`
|
|
1231
|
+
);
|
|
916
1232
|
try {
|
|
917
|
-
|
|
1233
|
+
execFileSync("curl", ["-fSL", "-o", tgzPath, url], { stdio: "inherit" });
|
|
918
1234
|
} catch {
|
|
919
|
-
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
`Could not download the collector from ${url}
|
|
1237
|
+
What happened: the download failed (network issue, or version ${version} has no asset for your platform).
|
|
1238
|
+
Fix: check your internet connection, or pin a different version with CCC_OTELCOL_VERSION=v0.155.0 (see the OpenTelemetry collector releases page).`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
const expectedSha256 = COLLECTOR_CHECKSUMS[asset];
|
|
1242
|
+
if (!expectedSha256) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
`Refusing to run an unverified collector.
|
|
1245
|
+
What happened: there is no pinned, known-good fingerprint on file for "${asset}" (version ${version}).
|
|
1246
|
+
Why: this tool refuses to make ANY downloaded program executable unless we can first confirm it's exactly the release we trust.
|
|
1247
|
+
Fix: install the built-in pinned version (unset CCC_OTELCOL_VERSION), or add this release's official signed SHA-256 to the package's pinned checksums before installing it. The download is left at ${tgzPath} so you can verify it yourself.`
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
log("Verifying the downloaded collector's fingerprint\u2026");
|
|
1251
|
+
if (!verifyTarballChecksum(tgzPath, expectedSha256)) {
|
|
1252
|
+
try {
|
|
1253
|
+
rmSync2(tgzPath, { force: true });
|
|
1254
|
+
} catch {
|
|
1255
|
+
}
|
|
1256
|
+
throw new Error(
|
|
1257
|
+
`Collector integrity check FAILED - refusing to install.
|
|
1258
|
+
What happened: the collector we downloaded for "${asset}" does NOT match its known-good fingerprint, so we deleted it and stopped.
|
|
1259
|
+
Why this probably happened: the file was altered between GitHub and your machine - most often a corporate TLS-inspecting proxy rewriting the download, a corrupted transfer, or (worst case) a tampered release.
|
|
1260
|
+
Fix: re-run the install on a network without a man-in-the-middle proxy. If it keeps failing on a clean network, do NOT bypass this - report it, because a persistent mismatch can mean the download is being tampered with.`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
log("Fingerprint verified - the collector is exactly the trusted release.");
|
|
1264
|
+
log(`Unpacking the collector into ${CONFIG_DIR}\u2026`);
|
|
1265
|
+
try {
|
|
1266
|
+
execFileSync("tar", ["xzf", tgzPath, "-C", CONFIG_DIR, "otelcol-contrib"], {
|
|
1267
|
+
stdio: "inherit"
|
|
1268
|
+
});
|
|
1269
|
+
} catch {
|
|
1270
|
+
throw new Error(
|
|
1271
|
+
`Could not unpack ${tgzPath}.
|
|
1272
|
+
What happened: the downloaded archive didn't contain the expected otelcol-contrib binary, or tar isn't available.
|
|
1273
|
+
Fix: delete the file above and re-run \`npx @marginfront/code-cost-clarity init\`.`
|
|
1274
|
+
);
|
|
1275
|
+
} finally {
|
|
1276
|
+
try {
|
|
1277
|
+
rmSync2(tgzPath, { force: true });
|
|
1278
|
+
} catch {
|
|
1279
|
+
}
|
|
920
1280
|
}
|
|
921
|
-
const lines = text.split("\n");
|
|
922
|
-
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
923
|
-
return lines.slice(-maxLines);
|
|
924
|
-
}
|
|
925
|
-
var ZSHRC_PATH = join(homedir(), ".zshrc");
|
|
926
|
-
function lineSourcesCccEnv(rawLine) {
|
|
927
|
-
const trimmed = rawLine.trim();
|
|
928
|
-
if (!trimmed || trimmed.startsWith("#")) return false;
|
|
929
|
-
if (!/^(source|\.)\s/.test(trimmed)) return false;
|
|
930
|
-
return trimmed.includes(".marginfront-ccc/.env");
|
|
931
|
-
}
|
|
932
|
-
function findCccZshrcSourceLines(zshrcPath = ZSHRC_PATH) {
|
|
933
|
-
if (!existsSync(zshrcPath)) return { found: false, matchedLines: [] };
|
|
934
|
-
let text;
|
|
935
1281
|
try {
|
|
936
|
-
|
|
1282
|
+
execFileSync("chmod", ["755", COLLECTOR_BIN_PATH]);
|
|
937
1283
|
} catch {
|
|
938
|
-
return { found: false, matchedLines: [] };
|
|
939
1284
|
}
|
|
940
|
-
|
|
941
|
-
return
|
|
1285
|
+
log(`Collector installed at ${COLLECTOR_BIN_PATH}.`);
|
|
1286
|
+
return COLLECTOR_BIN_PATH;
|
|
942
1287
|
}
|
|
943
|
-
function
|
|
944
|
-
zshrcPath = ZSHRC_PATH,
|
|
1288
|
+
function ensureConfigFiles({
|
|
945
1289
|
log = console.log
|
|
946
1290
|
} = {}) {
|
|
947
|
-
if (!
|
|
948
|
-
|
|
949
|
-
|
|
1291
|
+
if (!existsSync2(CONFIG_DIR)) {
|
|
1292
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1293
|
+
log(`Created ${CONFIG_DIR}.`);
|
|
1294
|
+
}
|
|
1295
|
+
if (existsSync2(ENV_PATH)) {
|
|
1296
|
+
log(`Kept your existing settings file at ${ENV_PATH} (API key preserved).`);
|
|
1297
|
+
} else {
|
|
1298
|
+
writeFileSync2(ENV_PATH, renderEnvFile(), { mode: 384 });
|
|
1299
|
+
log(`Wrote settings template to ${ENV_PATH} - add your API key there.`);
|
|
950
1300
|
}
|
|
1301
|
+
writeFileSync2(COLLECTOR_CONFIG_PATH, renderCollectorYaml());
|
|
1302
|
+
log(`Wrote collector config to ${COLLECTOR_CONFIG_PATH}.`);
|
|
1303
|
+
}
|
|
1304
|
+
function loadEnvFile(path = ENV_PATH) {
|
|
1305
|
+
if (!existsSync2(path)) return {};
|
|
1306
|
+
const loaded = {};
|
|
951
1307
|
let text;
|
|
952
1308
|
try {
|
|
953
|
-
text =
|
|
1309
|
+
text = readFileSync2(path, "utf8");
|
|
954
1310
|
} catch {
|
|
955
|
-
|
|
956
|
-
`Could not read ${zshrcPath}, so I left it untouched - remove the \`source ~/.marginfront-ccc/.env\` line by hand if you like.`
|
|
957
|
-
);
|
|
958
|
-
return { removed: [], backupPath: null };
|
|
959
|
-
}
|
|
960
|
-
const removed = [];
|
|
961
|
-
const kept = [];
|
|
962
|
-
for (const line of text.split("\n")) {
|
|
963
|
-
if (lineSourcesCccEnv(line)) removed.push(line.trim());
|
|
964
|
-
else kept.push(line);
|
|
1311
|
+
return {};
|
|
965
1312
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1313
|
+
for (const rawLine of text.split("\n")) {
|
|
1314
|
+
const line = rawLine.trim();
|
|
1315
|
+
if (!line || line.startsWith("#")) continue;
|
|
1316
|
+
const eq = line.indexOf("=");
|
|
1317
|
+
if (eq === -1) continue;
|
|
1318
|
+
const key = line.slice(0, eq).trim();
|
|
1319
|
+
let value = line.slice(eq + 1).trim();
|
|
1320
|
+
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
1321
|
+
value = value.slice(1, -1);
|
|
1322
|
+
}
|
|
1323
|
+
if (!key) continue;
|
|
1324
|
+
loaded[key] = value;
|
|
1325
|
+
if (process2.env[key] === void 0) {
|
|
1326
|
+
process2.env[key] = value;
|
|
1327
|
+
}
|
|
969
1328
|
}
|
|
970
|
-
|
|
971
|
-
copyFileSync(zshrcPath, backupPath);
|
|
972
|
-
writeFileSync(zshrcPath, kept.join("\n"));
|
|
973
|
-
log(
|
|
974
|
-
`Removed the redundant 'source ~/.marginfront-ccc/.env' line from ${zshrcPath} (backup at ${backupPath}).`
|
|
975
|
-
);
|
|
976
|
-
return { removed, backupPath };
|
|
1329
|
+
return loaded;
|
|
977
1330
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
appendFileSync,
|
|
989
|
-
rmSync as rmSync2
|
|
990
|
-
} from "fs";
|
|
991
|
-
import { createHash as createHash2 } from "crypto";
|
|
992
|
-
import { createRequire } from "module";
|
|
993
|
-
import process2 from "process";
|
|
994
|
-
var _cccRequire = createRequire(import.meta.url);
|
|
995
|
-
function resolveCccPackageVersion() {
|
|
996
|
-
if ("0.6.0".length > 0) {
|
|
997
|
-
return "0.6.0";
|
|
1331
|
+
function writeApiKeyToEnv(value, path = ENV_PATH) {
|
|
1332
|
+
const current = existsSync2(path) ? readFileSync2(path, "utf8") : renderEnvFile();
|
|
1333
|
+
const keyLine = /^MARGINFRONT_API_KEY=.*$/m;
|
|
1334
|
+
let next;
|
|
1335
|
+
if (keyLine.test(current)) {
|
|
1336
|
+
next = current.replace(keyLine, () => `MARGINFRONT_API_KEY=${value}`);
|
|
1337
|
+
} else {
|
|
1338
|
+
const sep = current.endsWith("\n") || current === "" ? "" : "\n";
|
|
1339
|
+
next = `${current}${sep}MARGINFRONT_API_KEY=${value}
|
|
1340
|
+
`;
|
|
998
1341
|
}
|
|
1342
|
+
writeFileSync2(path, next, { mode: 384 });
|
|
1343
|
+
chmodSync(path, 384);
|
|
1344
|
+
}
|
|
1345
|
+
function gitGlobalUserEmail() {
|
|
999
1346
|
try {
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1347
|
+
const out = execFileSync("git", ["config", "--global", "user.email"], {
|
|
1348
|
+
encoding: "utf8",
|
|
1349
|
+
// Swallow git's own stderr so a "key not set" message can't leak to the user.
|
|
1350
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1351
|
+
});
|
|
1352
|
+
return out.trim();
|
|
1004
1353
|
} catch {
|
|
1354
|
+
return "";
|
|
1005
1355
|
}
|
|
1006
|
-
return "0.0.0-unknown";
|
|
1007
1356
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1357
|
+
function writeDeveloperEmailToEnv(value, path = ENV_PATH) {
|
|
1358
|
+
const current = existsSync2(path) ? readFileSync2(path, "utf8") : renderEnvFile();
|
|
1359
|
+
const developerLine = /^CCC_DEVELOPER_EMAIL=.*$/m;
|
|
1360
|
+
let next;
|
|
1361
|
+
if (developerLine.test(current)) {
|
|
1362
|
+
next = current.replace(developerLine, () => `CCC_DEVELOPER_EMAIL=${value}`);
|
|
1363
|
+
} else {
|
|
1364
|
+
const sep = current.endsWith("\n") || current === "" ? "" : "\n";
|
|
1365
|
+
next = `${current}${sep}CCC_DEVELOPER_EMAIL=${value}
|
|
1366
|
+
`;
|
|
1367
|
+
}
|
|
1368
|
+
writeFileSync2(path, next, { mode: 384 });
|
|
1369
|
+
chmodSync(path, 384);
|
|
1370
|
+
}
|
|
1371
|
+
var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
1372
|
+
var CLAUDE_SETTINGS_OWNERSHIP_PATH = join(
|
|
1373
|
+
CONFIG_DIR,
|
|
1374
|
+
"claude-settings-owned.json"
|
|
1375
|
+
);
|
|
1376
|
+
var CCC_OWNED_TELEMETRY_KEYS = [
|
|
1377
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
1378
|
+
"OTEL_METRICS_EXPORTER",
|
|
1379
|
+
"OTEL_LOGS_EXPORTER",
|
|
1380
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
1381
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
1382
|
+
"OTEL_METRIC_EXPORT_INTERVAL"
|
|
1383
|
+
];
|
|
1384
|
+
var CCC_TELEMETRY_VALUES = {
|
|
1385
|
+
// Turns on Claude Code's usage broadcasting.
|
|
1386
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
1387
|
+
// Send token usage as OpenTelemetry metrics.
|
|
1388
|
+
OTEL_METRICS_EXPORTER: "otlp",
|
|
1389
|
+
// Send the per-tool-call EVENT stream too (needed for tool-call line items).
|
|
1390
|
+
OTEL_LOGS_EXPORTER: "otlp",
|
|
1391
|
+
// HTTP/protobuf - verified working; gRPC on 4317 silently failed in testing.
|
|
1392
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
|
|
1393
|
+
// Point Claude Code at the local collector (must match the collector YAML).
|
|
1394
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318",
|
|
1395
|
+
// Flush usage every 5 minutes (batched ~one event per turn).
|
|
1396
|
+
OTEL_METRIC_EXPORT_INTERVAL: "300000"
|
|
1397
|
+
};
|
|
1398
|
+
function isPlainObject(value) {
|
|
1399
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1400
|
+
}
|
|
1401
|
+
function backupClaudeSettings(settingsPath) {
|
|
1402
|
+
const backupPath = `${settingsPath}.ccc-bak`;
|
|
1403
|
+
copyFileSync(settingsPath, backupPath);
|
|
1404
|
+
return backupPath;
|
|
1405
|
+
}
|
|
1406
|
+
function readClaudeSettingsOwnership(ownershipPath) {
|
|
1407
|
+
if (!existsSync2(ownershipPath)) return null;
|
|
1012
1408
|
try {
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1409
|
+
const parsed = JSON.parse(readFileSync2(ownershipPath, "utf8"));
|
|
1410
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.ownedKeys)) {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
const out = {};
|
|
1414
|
+
for (const [key, value] of Object.entries(parsed.ownedKeys)) {
|
|
1415
|
+
out[key] = String(value);
|
|
1416
|
+
}
|
|
1417
|
+
return out;
|
|
1017
1418
|
} catch {
|
|
1419
|
+
return null;
|
|
1018
1420
|
}
|
|
1019
|
-
console.error(
|
|
1020
|
-
"Ignoring MARGINFRONT_INGEST_URL - only an https://*.marginfront.com URL is allowed; sending to production instead."
|
|
1021
|
-
);
|
|
1022
|
-
return DEFAULT_INGEST_URL;
|
|
1023
1421
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1422
|
+
function writeClaudeSettingsOwnership(ownershipPath, ownedKeys) {
|
|
1423
|
+
mkdirSync(dirname(ownershipPath), { recursive: true });
|
|
1424
|
+
const payload = {
|
|
1425
|
+
_comment: "MarginFront code-cost-clarity wrote these telemetry keys into your Claude Code settings.json. Uninstall removes ONLY the keys below, and only if their value still matches. Safe to delete by hand if you've already cleaned up settings.json yourself.",
|
|
1426
|
+
ownedKeys
|
|
1427
|
+
};
|
|
1428
|
+
writeFileSync2(ownershipPath, JSON.stringify(payload, null, 2) + "\n");
|
|
1027
1429
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
"Edit",
|
|
1057
|
-
"Write",
|
|
1058
|
-
"MultiEdit",
|
|
1059
|
-
"NotebookEdit",
|
|
1060
|
-
"NotebookRead",
|
|
1061
|
-
"Bash",
|
|
1062
|
-
"BashOutput",
|
|
1063
|
-
"KillShell",
|
|
1064
|
-
"KillBash",
|
|
1065
|
-
"Grep",
|
|
1066
|
-
"Glob",
|
|
1067
|
-
"LS",
|
|
1068
|
-
"TodoWrite",
|
|
1069
|
-
"ExitPlanMode",
|
|
1070
|
-
"Task",
|
|
1071
|
-
"Agent",
|
|
1072
|
-
// alternate name for Task - hedge against the rename
|
|
1073
|
-
"SlashCommand",
|
|
1074
|
-
"ToolSearch",
|
|
1075
|
-
// Codex built-ins (free + high-volume)
|
|
1076
|
-
"exec_command",
|
|
1077
|
-
"apply_patch"
|
|
1078
|
-
]);
|
|
1079
|
-
var ENVIRONMENT = "development";
|
|
1080
|
-
var WATCH_POLL_MS = 1e3;
|
|
1081
|
-
var NO_IDENTITY_CUSTOMER = "claude-code-no-identity";
|
|
1082
|
-
var CODEX_NO_IDENTITY_CUSTOMER = "codex-no-identity";
|
|
1083
|
-
function attributesToLookup(attributeList) {
|
|
1084
|
-
const lookup = {};
|
|
1085
|
-
if (!Array.isArray(attributeList)) return lookup;
|
|
1086
|
-
for (const attribute of attributeList) {
|
|
1087
|
-
if (attribute && typeof attribute.key === "string" && attribute.value) {
|
|
1088
|
-
lookup[attribute.key] = attribute.value.stringValue;
|
|
1430
|
+
function writeClaudeTelemetrySettings(vars, {
|
|
1431
|
+
settingsPath = CLAUDE_SETTINGS_PATH,
|
|
1432
|
+
ownershipPath = CLAUDE_SETTINGS_OWNERSHIP_PATH,
|
|
1433
|
+
log = console.log
|
|
1434
|
+
} = {}) {
|
|
1435
|
+
let settings = {};
|
|
1436
|
+
const fileExists = existsSync2(settingsPath);
|
|
1437
|
+
if (fileExists) {
|
|
1438
|
+
let parsed;
|
|
1439
|
+
try {
|
|
1440
|
+
parsed = JSON.parse(readFileSync2(settingsPath, "utf8"));
|
|
1441
|
+
} catch {
|
|
1442
|
+
const backupPath2 = backupClaudeSettings(settingsPath);
|
|
1443
|
+
throw new Error(
|
|
1444
|
+
`Could not update ${settingsPath}.
|
|
1445
|
+
What happened: your Claude Code settings file isn't valid JSON, so we stopped instead of risking your settings.
|
|
1446
|
+
What we did: saved an untouched copy at ${backupPath2}.
|
|
1447
|
+
Fix: open the file and repair the JSON (a common cause is a trailing comma or a missing quote), then run init again. We will not modify a file we can't read.`
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
if (!isPlainObject(parsed)) {
|
|
1451
|
+
const backupPath2 = backupClaudeSettings(settingsPath);
|
|
1452
|
+
throw new Error(
|
|
1453
|
+
`Could not update ${settingsPath}.
|
|
1454
|
+
What happened: the top level of your Claude Code settings file isn't a JSON object (it looks like an array or a single value), so there's nowhere safe to add the telemetry settings.
|
|
1455
|
+
What we did: saved an untouched copy at ${backupPath2}.
|
|
1456
|
+
Fix: make the file a normal JSON object like { "env": { ... } }, then run init again.`
|
|
1457
|
+
);
|
|
1089
1458
|
}
|
|
1459
|
+
settings = parsed;
|
|
1090
1460
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
if (dataPoint.asDouble !== void 0 && dataPoint.asDouble !== null) {
|
|
1100
|
-
const parsed = Math.round(Number(dataPoint.asDouble));
|
|
1101
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
1461
|
+
if ("env" in settings && !isPlainObject(settings.env)) {
|
|
1462
|
+
const backupPath2 = backupClaudeSettings(settingsPath);
|
|
1463
|
+
throw new Error(
|
|
1464
|
+
`Could not update ${settingsPath}.
|
|
1465
|
+
What happened: your settings file has an "env" entry that isn't a block of key/value settings (it's null, a list, or a single value), so we stopped instead of overwriting it.
|
|
1466
|
+
What we did: saved an untouched copy at ${backupPath2}.
|
|
1467
|
+
Fix: change "env" to a normal object like { "KEY": "value" } (or remove it), then run init again.`
|
|
1468
|
+
);
|
|
1102
1469
|
}
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (!
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1470
|
+
const hadEnv = isPlainObject(settings.env);
|
|
1471
|
+
const env = hadEnv ? settings.env : {};
|
|
1472
|
+
const priorOwned = readClaudeSettingsOwnership(ownershipPath) ?? {};
|
|
1473
|
+
const ownedKeys = {};
|
|
1474
|
+
const written = [];
|
|
1475
|
+
const skipped = [];
|
|
1476
|
+
let mutated = false;
|
|
1477
|
+
for (const key of CCC_OWNED_TELEMETRY_KEYS) {
|
|
1478
|
+
if (!(key in vars)) continue;
|
|
1479
|
+
const canonical = String(vars[key]);
|
|
1480
|
+
const current = env[key];
|
|
1481
|
+
if (current === void 0) {
|
|
1482
|
+
env[key] = canonical;
|
|
1483
|
+
ownedKeys[key] = canonical;
|
|
1484
|
+
written.push(key);
|
|
1485
|
+
mutated = true;
|
|
1486
|
+
} else if (current === canonical) {
|
|
1487
|
+
if (priorOwned[key] === canonical) {
|
|
1488
|
+
ownedKeys[key] = canonical;
|
|
1489
|
+
written.push(key);
|
|
1490
|
+
} else {
|
|
1491
|
+
skipped.push(key);
|
|
1122
1492
|
}
|
|
1493
|
+
} else {
|
|
1494
|
+
skipped.push(key);
|
|
1123
1495
|
}
|
|
1124
1496
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1497
|
+
let backupPath = null;
|
|
1498
|
+
if (mutated) {
|
|
1499
|
+
if (!hadEnv) settings.env = env;
|
|
1500
|
+
if (fileExists) backupPath = backupClaudeSettings(settingsPath);
|
|
1501
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
1502
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1503
|
+
log(
|
|
1504
|
+
`Wrote ${written.length} telemetry setting(s) into ${settingsPath}` + (backupPath ? ` (backup at ${backupPath}).` : ".")
|
|
1505
|
+
);
|
|
1506
|
+
} else {
|
|
1507
|
+
log(`No changes needed in ${settingsPath} - telemetry already in place.`);
|
|
1508
|
+
}
|
|
1509
|
+
writeClaudeSettingsOwnership(ownershipPath, ownedKeys);
|
|
1510
|
+
if (skipped.length) {
|
|
1511
|
+
log(
|
|
1512
|
+
`Left ${skipped.length} setting(s) you already had in place untouched: ` + skipped.join(", ")
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
return { written, skipped, backupPath };
|
|
1140
1516
|
}
|
|
1141
|
-
function
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
);
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
function makeGroupKey(email, model, sessionId) {
|
|
1155
|
-
return `${email}
|
|
1156
|
-
${model}
|
|
1157
|
-
${sessionId}`;
|
|
1517
|
+
function removeClaudeTelemetrySettings({
|
|
1518
|
+
settingsPath = CLAUDE_SETTINGS_PATH,
|
|
1519
|
+
ownershipPath = CLAUDE_SETTINGS_OWNERSHIP_PATH,
|
|
1520
|
+
log = console.log
|
|
1521
|
+
} = {}) {
|
|
1522
|
+
const removed = [];
|
|
1523
|
+
const kept = [];
|
|
1524
|
+
const owned = readClaudeSettingsOwnership(ownershipPath);
|
|
1525
|
+
if (owned === null) {
|
|
1526
|
+
log(
|
|
1527
|
+
`Nothing to undo: no MarginFront ownership record found at ${ownershipPath} (either it was never written, or it's already been removed).`
|
|
1528
|
+
);
|
|
1529
|
+
return { removed, kept, backupPath: null };
|
|
1158
1530
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
const tokenCount = dataPointTokenCount(dataPoint);
|
|
1166
|
-
if (!email || !rawModel || !sessionId) continue;
|
|
1167
|
-
const groupKey = makeGroupKey(email, rawModel, sessionId);
|
|
1168
|
-
let group = groupsByKey.get(groupKey);
|
|
1169
|
-
if (!group) {
|
|
1170
|
-
group = {
|
|
1171
|
-
email,
|
|
1172
|
-
rawModel,
|
|
1173
|
-
sessionId,
|
|
1174
|
-
inputTokens: 0,
|
|
1175
|
-
outputTokens: 0,
|
|
1176
|
-
cacheReadTokens: 0,
|
|
1177
|
-
cacheCreationTokens: 0
|
|
1178
|
-
};
|
|
1179
|
-
groupsByKey.set(groupKey, group);
|
|
1180
|
-
}
|
|
1181
|
-
if (tokenType === "input") {
|
|
1182
|
-
group.inputTokens += tokenCount;
|
|
1183
|
-
} else if (tokenType === "output") {
|
|
1184
|
-
group.outputTokens += tokenCount;
|
|
1185
|
-
} else if (tokenType === "cacheRead") {
|
|
1186
|
-
group.cacheReadTokens += tokenCount;
|
|
1187
|
-
} else if (tokenType === "cacheCreation") {
|
|
1188
|
-
group.cacheCreationTokens += tokenCount;
|
|
1189
|
-
}
|
|
1531
|
+
if (!existsSync2(settingsPath)) {
|
|
1532
|
+
log(
|
|
1533
|
+
`Nothing to undo: ${settingsPath} doesn't exist. Removing the stale ownership record.`
|
|
1534
|
+
);
|
|
1535
|
+
rmSync2(ownershipPath, { force: true });
|
|
1536
|
+
return { removed, kept, backupPath: null };
|
|
1190
1537
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
}
|
|
1199
|
-
return null;
|
|
1538
|
+
let parsed;
|
|
1539
|
+
try {
|
|
1540
|
+
parsed = JSON.parse(readFileSync2(settingsPath, "utf8"));
|
|
1541
|
+
} catch {
|
|
1542
|
+
log(
|
|
1543
|
+
`Could not undo cleanly: ${settingsPath} isn't valid JSON right now, so we left it untouched. Fix the JSON and re-run uninstall, or remove the telemetry keys by hand.`
|
|
1544
|
+
);
|
|
1545
|
+
return { removed, kept, backupPath: null };
|
|
1200
1546
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const cacheCreationTokens = group.cacheCreationTokens;
|
|
1205
|
-
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
|
1206
|
-
const billedInputTokens = foldCache ? group.inputTokens + cacheTokens : group.inputTokens;
|
|
1207
|
-
if (group.inputTokens === 0 && group.outputTokens === 0 && cacheTokens === 0)
|
|
1208
|
-
continue;
|
|
1209
|
-
const claudeCodeCostUsd = findCostForGroup(
|
|
1210
|
-
group.email,
|
|
1211
|
-
group.rawModel,
|
|
1212
|
-
group.sessionId
|
|
1547
|
+
if (!isPlainObject(parsed)) {
|
|
1548
|
+
log(
|
|
1549
|
+
`Could not undo cleanly: ${settingsPath} isn't a JSON object, so we left it untouched.`
|
|
1213
1550
|
);
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
metadata: {
|
|
1227
|
-
sessionId: group.sessionId,
|
|
1228
|
-
rawModel: group.rawModel,
|
|
1229
|
-
// Raw cache numbers ALWAYS recorded (audit), whether typed or folded.
|
|
1230
|
-
cacheReadTokens,
|
|
1231
|
-
cacheCreationTokens,
|
|
1232
|
-
// Whether inputTokens includes cache (fold) or not (default).
|
|
1233
|
-
cacheFoldedIntoInput: foldCache,
|
|
1234
|
-
// Claude Code's own cache-accurate cost - reconciliation ground truth,
|
|
1235
|
-
// not the billed figure.
|
|
1236
|
-
claudeCodeCostUsd
|
|
1237
|
-
}
|
|
1238
|
-
};
|
|
1239
|
-
if (!foldCache) {
|
|
1240
|
-
record.cacheReadTokens = cacheReadTokens;
|
|
1241
|
-
record.cacheWriteTokens = cacheCreationTokens;
|
|
1242
|
-
}
|
|
1243
|
-
if (options.rawSourceLine !== void 0) {
|
|
1244
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1245
|
-
options.rawSourceLine,
|
|
1246
|
-
group.email,
|
|
1247
|
-
group.rawModel,
|
|
1248
|
-
group.sessionId,
|
|
1249
|
-
records.length
|
|
1250
|
-
);
|
|
1551
|
+
return { removed, kept, backupPath: null };
|
|
1552
|
+
}
|
|
1553
|
+
const settings = parsed;
|
|
1554
|
+
const env = isPlainObject(settings.env) ? settings.env : null;
|
|
1555
|
+
let mutated = false;
|
|
1556
|
+
for (const [key, canonical] of Object.entries(owned)) {
|
|
1557
|
+
if (env && env[key] === canonical) {
|
|
1558
|
+
delete env[key];
|
|
1559
|
+
removed.push(key);
|
|
1560
|
+
mutated = true;
|
|
1561
|
+
} else {
|
|
1562
|
+
kept.push(key);
|
|
1251
1563
|
}
|
|
1252
|
-
records.push(record);
|
|
1253
1564
|
}
|
|
1254
|
-
|
|
1565
|
+
if (env && Object.keys(env).length === 0) {
|
|
1566
|
+
delete settings.env;
|
|
1567
|
+
mutated = true;
|
|
1568
|
+
}
|
|
1569
|
+
let backupPath = null;
|
|
1570
|
+
if (mutated) {
|
|
1571
|
+
backupPath = backupClaudeSettings(settingsPath);
|
|
1572
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1573
|
+
log(
|
|
1574
|
+
`Removed ${removed.length} MarginFront telemetry setting(s) from ${settingsPath} (backup at ${backupPath}).`
|
|
1575
|
+
);
|
|
1576
|
+
} else {
|
|
1577
|
+
log(
|
|
1578
|
+
`Nothing to remove from ${settingsPath} - the telemetry keys were already gone or you'd changed them (left untouched).`
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
rmSync2(ownershipPath, { force: true });
|
|
1582
|
+
return { removed, kept, backupPath };
|
|
1255
1583
|
}
|
|
1256
|
-
function
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1584
|
+
function isPidAlive(pid) {
|
|
1585
|
+
if (!pid || !Number.isInteger(pid)) return false;
|
|
1586
|
+
try {
|
|
1587
|
+
process2.kill(pid, 0);
|
|
1588
|
+
return true;
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
return err?.code === "EPERM";
|
|
1263
1591
|
}
|
|
1264
|
-
return lookup;
|
|
1265
1592
|
}
|
|
1266
|
-
function
|
|
1267
|
-
if (
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const parsed = parseInt(String(value.intValue), 10);
|
|
1274
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
1593
|
+
function readCollectorPid() {
|
|
1594
|
+
if (!existsSync2(COLLECTOR_PID_PATH)) return null;
|
|
1595
|
+
try {
|
|
1596
|
+
const pid = parseInt(readFileSync2(COLLECTOR_PID_PATH, "utf8").trim(), 10);
|
|
1597
|
+
return Number.isInteger(pid) ? pid : null;
|
|
1598
|
+
} catch {
|
|
1599
|
+
return null;
|
|
1275
1600
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1601
|
+
}
|
|
1602
|
+
function startCollector({ log = console.log } = {}) {
|
|
1603
|
+
const existing = readCollectorPid();
|
|
1604
|
+
if (isPidAlive(existing)) {
|
|
1605
|
+
log(`Collector already running (pid ${existing}).`);
|
|
1606
|
+
return { pid: existing, startedByUs: false };
|
|
1279
1607
|
}
|
|
1280
|
-
if (
|
|
1281
|
-
|
|
1282
|
-
|
|
1608
|
+
if (!existsSync2(COLLECTOR_BIN_PATH)) {
|
|
1609
|
+
throw new Error(
|
|
1610
|
+
"Collector binary is missing. Run `npx @marginfront/code-cost-clarity init` first."
|
|
1611
|
+
);
|
|
1283
1612
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1613
|
+
const logFd = openSync2(COLLECTOR_LOG_PATH, "a");
|
|
1614
|
+
const child = spawn(COLLECTOR_BIN_PATH, ["--config", COLLECTOR_CONFIG_PATH], {
|
|
1615
|
+
cwd: CONFIG_DIR,
|
|
1616
|
+
detached: true,
|
|
1617
|
+
stdio: ["ignore", logFd, logFd]
|
|
1618
|
+
});
|
|
1619
|
+
child.unref();
|
|
1620
|
+
writeFileSync2(COLLECTOR_PID_PATH, String(child.pid));
|
|
1621
|
+
log(`Started collector (pid ${child.pid}); logs at ${COLLECTOR_LOG_PATH}.`);
|
|
1622
|
+
return { pid: child.pid, startedByUs: true };
|
|
1289
1623
|
}
|
|
1290
|
-
function
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
if (!Array.isArray(scopeLogsList)) continue;
|
|
1300
|
-
for (const scopeLog of scopeLogsList) {
|
|
1301
|
-
const logRecordsList = scopeLog?.logRecords;
|
|
1302
|
-
if (!Array.isArray(logRecordsList)) continue;
|
|
1303
|
-
for (const logRecord of logRecordsList) {
|
|
1304
|
-
const lookup = logAttributesToLookup(logRecord?.attributes);
|
|
1305
|
-
const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
|
|
1306
|
-
if (toolResultSource(eventName) !== null) continue;
|
|
1307
|
-
if (!isCodexUsageRecord(lookup, eventName)) continue;
|
|
1308
|
-
const rawModel = logAttrString(lookup["model"]);
|
|
1309
|
-
if (!rawModel) continue;
|
|
1310
|
-
if (CODEX_EXCLUDED_MODELS.has(rawModel.trim().toLowerCase())) continue;
|
|
1311
|
-
const email = developerOverride || logAttrString(lookup["user.email"]) || fallbackCustomerId;
|
|
1312
|
-
if (!email) continue;
|
|
1313
|
-
const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
|
|
1314
|
-
const inputCount = logAttrTokenCount(lookup[CODEX_INPUT_TOKEN_KEY]);
|
|
1315
|
-
const outputCount = logAttrTokenCount(lookup[CODEX_OUTPUT_TOKEN_KEY]);
|
|
1316
|
-
const cachedCount = logAttrTokenCount(lookup[CODEX_CACHED_TOKEN_KEY]);
|
|
1317
|
-
const reasoningCount = logAttrTokenCount(
|
|
1318
|
-
lookup[CODEX_REASONING_TOKEN_KEY]
|
|
1319
|
-
);
|
|
1320
|
-
const toolCount = logAttrTokenCount(lookup[CODEX_TOOL_TOKEN_KEY]);
|
|
1321
|
-
if (inputCount === 0 && outputCount === 0 && cachedCount === 0)
|
|
1322
|
-
continue;
|
|
1323
|
-
const freshInputTokens = Math.max(0, inputCount - cachedCount);
|
|
1324
|
-
const billedInputTokens = foldCache ? inputCount : freshInputTokens;
|
|
1325
|
-
const record = {
|
|
1326
|
-
customerExternalId: email,
|
|
1327
|
-
agentCode: AGENT_CODE_CODEX,
|
|
1328
|
-
signalName: SIGNAL_NAME_CODEX,
|
|
1329
|
-
model: normalizeModelId(rawModel),
|
|
1330
|
-
modelProvider: MODEL_PROVIDER_CODEX,
|
|
1331
|
-
inputTokens: billedInputTokens,
|
|
1332
|
-
// Reasoning is ALREADY inside this number - do NOT add it again.
|
|
1333
|
-
outputTokens: outputCount,
|
|
1334
|
-
environment: ENVIRONMENT,
|
|
1335
|
-
// usageDate omitted so MarginFront defaults it to "now."
|
|
1336
|
-
metadata: {
|
|
1337
|
-
source: "codex",
|
|
1338
|
-
sessionId,
|
|
1339
|
-
rawModel,
|
|
1340
|
-
// Audit-only - nested inside input/output already, never added to the
|
|
1341
|
-
// billed tokens; recorded to prove we didn't drop anything.
|
|
1342
|
-
cachedTokens: cachedCount,
|
|
1343
|
-
reasoningTokens: reasoningCount,
|
|
1344
|
-
toolTokens: toolCount,
|
|
1345
|
-
cacheFoldedIntoInput: foldCache
|
|
1346
|
-
}
|
|
1347
|
-
};
|
|
1348
|
-
if (!foldCache) {
|
|
1349
|
-
record.cacheReadTokens = cachedCount;
|
|
1350
|
-
}
|
|
1351
|
-
if (options.rawSourceLine !== void 0) {
|
|
1352
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1353
|
-
options.rawSourceLine,
|
|
1354
|
-
email,
|
|
1355
|
-
rawModel,
|
|
1356
|
-
sessionId,
|
|
1357
|
-
records.length
|
|
1358
|
-
);
|
|
1359
|
-
}
|
|
1360
|
-
records.push(record);
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1624
|
+
function stopCollector({
|
|
1625
|
+
log = console.log
|
|
1626
|
+
} = {}) {
|
|
1627
|
+
const pid = readCollectorPid();
|
|
1628
|
+
if (!isPidAlive(pid)) {
|
|
1629
|
+
if (existsSync2(COLLECTOR_PID_PATH))
|
|
1630
|
+
rmSync2(COLLECTOR_PID_PATH, { force: true });
|
|
1631
|
+
log("No running collector found.");
|
|
1632
|
+
return false;
|
|
1363
1633
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
if (eventName === CLAUDE_TOOL_RESULT_EVENT || eventName === "tool_result") {
|
|
1370
|
-
return "claude-code";
|
|
1634
|
+
try {
|
|
1635
|
+
process2.kill(pid);
|
|
1636
|
+
log(`Stopped collector (pid ${pid}).`);
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
log(`Could not stop collector (pid ${pid}): ${err.message}`);
|
|
1371
1639
|
}
|
|
1372
|
-
|
|
1640
|
+
rmSync2(COLLECTOR_PID_PATH, { force: true });
|
|
1641
|
+
return true;
|
|
1373
1642
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
break;
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
if (!toolName) continue;
|
|
1401
|
-
if (BUILTIN_NONBILLABLE_TOOLS.has(toolName)) continue;
|
|
1402
|
-
const email = developerOverride || logAttrString(lookup["user.email"]) || fallbackCustomerId;
|
|
1403
|
-
if (!email) continue;
|
|
1404
|
-
const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
|
|
1405
|
-
const groupKey = `${email}
|
|
1406
|
-
${sessionId ?? ""}
|
|
1407
|
-
${source}
|
|
1408
|
-
${toolName}`;
|
|
1409
|
-
let group = groups.get(groupKey);
|
|
1410
|
-
if (!group) {
|
|
1411
|
-
group = {
|
|
1412
|
-
email,
|
|
1413
|
-
sessionId,
|
|
1414
|
-
telemetrySource: source,
|
|
1415
|
-
toolName,
|
|
1416
|
-
count: 0
|
|
1417
|
-
};
|
|
1418
|
-
groups.set(groupKey, group);
|
|
1419
|
-
}
|
|
1420
|
-
group.count += 1;
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
for (const group of groups.values()) {
|
|
1425
|
-
if (group.count === 0) continue;
|
|
1426
|
-
const isCodex = group.telemetrySource === "codex";
|
|
1427
|
-
const record = {
|
|
1428
|
-
customerExternalId: group.email,
|
|
1429
|
-
agentCode: isCodex ? AGENT_CODE_CODEX : AGENT_CODE,
|
|
1430
|
-
signalName: isCodex ? SIGNAL_NAME_TOOL_CODEX : SIGNAL_NAME_TOOL,
|
|
1431
|
-
// Raw tool NAME as the service id, verbatim - NOT through normalizeModelId
|
|
1432
|
-
// (that's Claude-LLM-name-specific). Catalog matches (model=tool name,
|
|
1433
|
-
// modelProvider="tool").
|
|
1434
|
-
model: group.toolName,
|
|
1435
|
-
modelProvider: MODEL_PROVIDER_TOOL,
|
|
1436
|
-
// Billing volume = times this tool fired this flush; no token fields (not an
|
|
1437
|
-
// LLM turn). Server prices quantity x unitPrice.
|
|
1438
|
-
quantity: group.count,
|
|
1439
|
-
environment: ENVIRONMENT,
|
|
1440
|
-
metadata: {
|
|
1441
|
-
source: "tool",
|
|
1442
|
-
sessionId: group.sessionId,
|
|
1443
|
-
toolName: group.toolName,
|
|
1444
|
-
telemetrySource: group.telemetrySource,
|
|
1445
|
-
estimated: true
|
|
1446
|
-
}
|
|
1447
|
-
};
|
|
1448
|
-
if (options.rawSourceLine !== void 0) {
|
|
1449
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1450
|
-
options.rawSourceLine,
|
|
1451
|
-
group.email,
|
|
1452
|
-
`tool:${group.toolName}`,
|
|
1453
|
-
group.sessionId,
|
|
1454
|
-
records.length
|
|
1455
|
-
);
|
|
1456
|
-
}
|
|
1457
|
-
records.push(record);
|
|
1643
|
+
var PLIST_LABEL = "ai.marginfront.ccc";
|
|
1644
|
+
var PLIST_PATH = join(
|
|
1645
|
+
homedir(),
|
|
1646
|
+
"Library",
|
|
1647
|
+
"LaunchAgents",
|
|
1648
|
+
PLIST_LABEL + ".plist"
|
|
1649
|
+
);
|
|
1650
|
+
var DAEMON_CLI_PATH = join(CONFIG_DIR, "cli.js");
|
|
1651
|
+
var DAEMON_STDOUT_LOG_PATH = join(CONFIG_DIR, "forwarder.out.log");
|
|
1652
|
+
var DAEMON_STDERR_LOG_PATH = join(CONFIG_DIR, "forwarder.err.log");
|
|
1653
|
+
function defaultLaunchctlRunner(args) {
|
|
1654
|
+
const result = spawnSync("launchctl", args, { encoding: "utf8" });
|
|
1655
|
+
return {
|
|
1656
|
+
// A spawn error (e.g. launchctl missing on a non-mac) leaves status null; we
|
|
1657
|
+
// treat that as a failure so callers don't read it as "succeeded."
|
|
1658
|
+
status: typeof result.status === "number" ? result.status : 1,
|
|
1659
|
+
stdout: result.stdout ?? "",
|
|
1660
|
+
stderr: result.stderr ?? ""
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function setDesktopGuiEnv(runLaunchctl = defaultLaunchctlRunner) {
|
|
1664
|
+
for (const key of CCC_OWNED_TELEMETRY_KEYS) {
|
|
1665
|
+
runLaunchctl(["setenv", key, CCC_TELEMETRY_VALUES[key]]);
|
|
1458
1666
|
}
|
|
1459
|
-
return { records };
|
|
1460
1667
|
}
|
|
1461
|
-
function
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
const tokenBody = buildCodexRequestBody(parsedOtlp, codexOptions);
|
|
1465
|
-
const toolBody = buildToolUsageRecords(parsedOtlp, codexOptions);
|
|
1466
|
-
return { records: [...tokenBody.records, ...toolBody.records] };
|
|
1668
|
+
function unsetDesktopGuiEnv(runLaunchctl = defaultLaunchctlRunner) {
|
|
1669
|
+
for (const key of CCC_OWNED_TELEMETRY_KEYS) {
|
|
1670
|
+
runLaunchctl(["unsetenv", key]);
|
|
1467
1671
|
}
|
|
1468
|
-
return buildMarginFrontRequestBody(parsedOtlp, options);
|
|
1469
1672
|
}
|
|
1470
|
-
function
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1673
|
+
function currentUid() {
|
|
1674
|
+
return typeof process2.getuid === "function" ? process2.getuid() : 0;
|
|
1675
|
+
}
|
|
1676
|
+
function escapeXml(value) {
|
|
1677
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1678
|
+
}
|
|
1679
|
+
function renderDaemonPlist({
|
|
1680
|
+
nodePath,
|
|
1681
|
+
cliPath
|
|
1682
|
+
}) {
|
|
1683
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1684
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1685
|
+
<plist version="1.0">
|
|
1686
|
+
<dict>
|
|
1687
|
+
<key>Label</key>
|
|
1688
|
+
<string>${escapeXml(PLIST_LABEL)}</string>
|
|
1689
|
+
<key>ProgramArguments</key>
|
|
1690
|
+
<array>
|
|
1691
|
+
<string>${escapeXml(nodePath)}</string>
|
|
1692
|
+
<string>${escapeXml(cliPath)}</string>
|
|
1693
|
+
<string>run</string>
|
|
1694
|
+
</array>
|
|
1695
|
+
<key>WorkingDirectory</key>
|
|
1696
|
+
<string>${escapeXml(CONFIG_DIR)}</string>
|
|
1697
|
+
<key>RunAtLoad</key>
|
|
1698
|
+
<true/>
|
|
1699
|
+
<key>KeepAlive</key>
|
|
1700
|
+
<true/>
|
|
1701
|
+
<key>StandardOutPath</key>
|
|
1702
|
+
<string>${escapeXml(DAEMON_STDOUT_LOG_PATH)}</string>
|
|
1703
|
+
<key>StandardErrorPath</key>
|
|
1704
|
+
<string>${escapeXml(DAEMON_STDERR_LOG_PATH)}</string>
|
|
1705
|
+
</dict>
|
|
1706
|
+
</plist>
|
|
1707
|
+
`;
|
|
1708
|
+
}
|
|
1709
|
+
var INSTALL_BOOTOUT_WAIT_ATTEMPTS = 20;
|
|
1710
|
+
var INSTALL_BOOTOUT_WAIT_MS = 100;
|
|
1711
|
+
var INSTALL_BOOTSTRAP_RETRY_MS = 250;
|
|
1712
|
+
function blockingSleep(ms) {
|
|
1713
|
+
if (ms <= 0) return;
|
|
1714
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1715
|
+
}
|
|
1716
|
+
function installDaemon(options = {}) {
|
|
1717
|
+
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
1718
|
+
const log = options.log ?? console.log;
|
|
1719
|
+
const nodePath = options.nodePath ?? process2.execPath;
|
|
1720
|
+
const sourceCliPath = options.sourceCliPath ?? fileURLToPath(import.meta.url);
|
|
1721
|
+
const daemonCliPath = options.daemonCliPath ?? DAEMON_CLI_PATH;
|
|
1722
|
+
const plistPath = options.plistPath ?? PLIST_PATH;
|
|
1723
|
+
const uid = options.uid ?? currentUid();
|
|
1724
|
+
const sleep = options.sleep ?? blockingSleep;
|
|
1725
|
+
if (nodePath === "npx" || nodePath.endsWith("/npx")) {
|
|
1726
|
+
throw new Error(
|
|
1727
|
+
"Refusing to install the background daemon with 'npx' as its program.\nWhat happened: the daemon must be pinned to the real node binary (process.execPath), because npx runs from a temporary cache that gets cleaned up - a daemon pointed at npx would crash-loop once that cache is gone.\nFix: this is an internal error; the daemon should always be installed with process.execPath. Re-run `start` from the npx-installed CLI."
|
|
1474
1728
|
);
|
|
1475
|
-
process2.exit(1);
|
|
1476
1729
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1730
|
+
mkdirSync(dirname(daemonCliPath), { recursive: true });
|
|
1731
|
+
copyFileSync(sourceCliPath, daemonCliPath);
|
|
1732
|
+
log(
|
|
1733
|
+
`Copied the meter program to ${daemonCliPath} (a stable copy that survives npx cleanup).`
|
|
1734
|
+
);
|
|
1735
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1736
|
+
writeFileSync2(
|
|
1737
|
+
plistPath,
|
|
1738
|
+
renderDaemonPlist({ nodePath, cliPath: daemonCliPath })
|
|
1739
|
+
);
|
|
1740
|
+
log(`Wrote the background-job definition to ${plistPath}.`);
|
|
1741
|
+
const domainTarget = `gui/${uid}`;
|
|
1742
|
+
const serviceTarget = `gui/${uid}/${PLIST_LABEL}`;
|
|
1743
|
+
const alreadyLoaded = runLaunchctl(["print", serviceTarget]).status === 0;
|
|
1744
|
+
let reloaded = false;
|
|
1745
|
+
if (alreadyLoaded) {
|
|
1746
|
+
log(
|
|
1747
|
+
"A background meter is already loaded - reloading it with the new settings."
|
|
1483
1748
|
);
|
|
1484
|
-
|
|
1749
|
+
runLaunchctl(["bootout", serviceTarget]);
|
|
1750
|
+
reloaded = true;
|
|
1751
|
+
for (let attempt = 0; attempt < INSTALL_BOOTOUT_WAIT_ATTEMPTS; attempt++) {
|
|
1752
|
+
if (runLaunchctl(["print", serviceTarget]).status !== 0) break;
|
|
1753
|
+
sleep(INSTALL_BOOTOUT_WAIT_MS);
|
|
1754
|
+
}
|
|
1485
1755
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1756
|
+
runLaunchctl(["enable", serviceTarget]);
|
|
1757
|
+
let bootstrap = runLaunchctl(["bootstrap", domainTarget, plistPath]);
|
|
1758
|
+
if (bootstrap.status !== 0) {
|
|
1759
|
+
sleep(INSTALL_BOOTSTRAP_RETRY_MS);
|
|
1760
|
+
bootstrap = runLaunchctl(["bootstrap", domainTarget, plistPath]);
|
|
1761
|
+
}
|
|
1762
|
+
if (bootstrap.status !== 0) {
|
|
1763
|
+
throw new Error(
|
|
1764
|
+
`Could not start the background meter (launchctl bootstrap failed).
|
|
1765
|
+
What happened: macOS refused to load the job at ${plistPath}.
|
|
1766
|
+
` + (bootstrap.stderr ? `launchctl said: ${bootstrap.stderr.trim()}
|
|
1767
|
+
` : "") + `Running CCC version: ${CCC_PACKAGE_VERSION}.
|
|
1768
|
+
Fix: run \`npx @marginfront/code-cost-clarity@latest status\` to see the current state, or \`stop\` then \`start\` again. The @latest pin makes sure a stale npx cache isn't hiding the real version. If it keeps failing, confirm you're on macOS and that ${plistPath} looks like valid XML.`
|
|
1491
1769
|
);
|
|
1492
|
-
process2.exit(1);
|
|
1493
1770
|
}
|
|
1771
|
+
log("The background meter is loaded and will start at login from now on.");
|
|
1772
|
+
return { reloaded, plistPath, daemonCliPath };
|
|
1494
1773
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
headers: {
|
|
1505
|
-
"Content-Type": "application/json",
|
|
1506
|
-
// MarginFront authenticates with x-api-key, NOT Bearer/Authorization.
|
|
1507
|
-
"x-api-key": apiKey,
|
|
1508
|
-
// This User-Agent is what lets the server tag these events as source='ccc'
|
|
1509
|
-
// instead of the generic 'api' fallback. Without it, PostHog dashboards
|
|
1510
|
-
// can't distinguish "developer paying for AI tokens via CCC" traffic from
|
|
1511
|
-
// raw API clients - every CCC event would be mislabelled as source='api'.
|
|
1512
|
-
"User-Agent": `@marginfront/code-cost-clarity/${CCC_PACKAGE_VERSION}`
|
|
1513
|
-
},
|
|
1514
|
-
body: JSON.stringify(requestBody)
|
|
1515
|
-
});
|
|
1516
|
-
} catch (networkError) {
|
|
1517
|
-
return {
|
|
1518
|
-
ok: false,
|
|
1519
|
-
reason: "network",
|
|
1520
|
-
message: networkError.message
|
|
1521
|
-
};
|
|
1522
|
-
}
|
|
1523
|
-
const responseText = await response.text();
|
|
1524
|
-
if (!response.ok) {
|
|
1525
|
-
return {
|
|
1526
|
-
ok: false,
|
|
1527
|
-
reason: "http",
|
|
1528
|
-
status: response.status,
|
|
1529
|
-
body: responseText
|
|
1530
|
-
};
|
|
1531
|
-
}
|
|
1532
|
-
let parsed = null;
|
|
1533
|
-
try {
|
|
1534
|
-
parsed = JSON.parse(responseText);
|
|
1535
|
-
} catch {
|
|
1536
|
-
parsed = null;
|
|
1774
|
+
var CCC_LABEL_PATTERN = /^ai\.marginfront\.ccc(\.|$)/;
|
|
1775
|
+
function defaultLaunchdLabelLister(runLaunchctl, uid) {
|
|
1776
|
+
const labels = /* @__PURE__ */ new Set();
|
|
1777
|
+
const list = runLaunchctl(["list"]);
|
|
1778
|
+
if (list.status === 0) {
|
|
1779
|
+
for (const line of list.stdout.split("\n")) {
|
|
1780
|
+
const label = (line.split(" ")[2] ?? "").trim();
|
|
1781
|
+
if (label) labels.add(label);
|
|
1782
|
+
}
|
|
1537
1783
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
if (!cursorPath || !existsSync2(cursorPath)) return 0;
|
|
1546
|
-
try {
|
|
1547
|
-
const parsed = parseInt(readFileSync2(cursorPath, "utf8").trim(), 10);
|
|
1548
|
-
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
1549
|
-
} catch {
|
|
1550
|
-
return 0;
|
|
1784
|
+
const disabled = runLaunchctl(["print-disabled", `gui/${uid}`]);
|
|
1785
|
+
if (disabled.status === 0) {
|
|
1786
|
+
const quoted = /"([^"]+)"\s*=>/g;
|
|
1787
|
+
let match;
|
|
1788
|
+
while ((match = quoted.exec(disabled.stdout)) !== null) {
|
|
1789
|
+
labels.add(match[1]);
|
|
1790
|
+
}
|
|
1551
1791
|
}
|
|
1792
|
+
return [...labels];
|
|
1552
1793
|
}
|
|
1553
|
-
function
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1794
|
+
function uninstallDaemon(options = {}) {
|
|
1795
|
+
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
1796
|
+
const log = options.log ?? console.log;
|
|
1797
|
+
const daemonCliPath = options.daemonCliPath ?? DAEMON_CLI_PATH;
|
|
1798
|
+
const plistPath = options.plistPath ?? PLIST_PATH;
|
|
1799
|
+
const uid = options.uid ?? currentUid();
|
|
1800
|
+
const listLabels = options.labelLister ?? defaultLaunchdLabelLister;
|
|
1801
|
+
const toBootOut = /* @__PURE__ */ new Set([PLIST_LABEL]);
|
|
1802
|
+
for (const label of listLabels(runLaunchctl, uid)) {
|
|
1803
|
+
if (CCC_LABEL_PATTERN.test(label)) toBootOut.add(label);
|
|
1558
1804
|
}
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
if (!existsSync2(filePath)) return { lines: [], nextCursor: fromByte };
|
|
1562
|
-
let size;
|
|
1563
|
-
try {
|
|
1564
|
-
size = statSync(filePath).size;
|
|
1565
|
-
} catch {
|
|
1566
|
-
return { lines: [], nextCursor: fromByte };
|
|
1805
|
+
for (const label of toBootOut) {
|
|
1806
|
+
runLaunchctl(["bootout", `gui/${uid}/${label}`]);
|
|
1567
1807
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
const length = size - start;
|
|
1572
|
-
const buffer = Buffer.alloc(length);
|
|
1573
|
-
let bytesRead = 0;
|
|
1574
|
-
let fd;
|
|
1575
|
-
try {
|
|
1576
|
-
fd = openSync2(filePath, "r");
|
|
1577
|
-
bytesRead = readSync(fd, buffer, 0, length, start);
|
|
1578
|
-
} catch {
|
|
1579
|
-
return { lines: [], nextCursor: fromByte };
|
|
1580
|
-
} finally {
|
|
1581
|
-
if (fd !== void 0) closeSync(fd);
|
|
1808
|
+
if (existsSync2(plistPath)) {
|
|
1809
|
+
rmSync2(plistPath, { force: true });
|
|
1810
|
+
log(`Removed the background-job definition at ${plistPath}.`);
|
|
1582
1811
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
return { lines: [], nextCursor: start };
|
|
1812
|
+
if (existsSync2(daemonCliPath)) {
|
|
1813
|
+
rmSync2(daemonCliPath, { force: true });
|
|
1814
|
+
log(`Removed the meter program copy at ${daemonCliPath}.`);
|
|
1587
1815
|
}
|
|
1588
|
-
const completeText = chunk.subarray(0, lastNewline + 1).toString("utf8");
|
|
1589
|
-
const nextCursor = start + lastNewline + 1;
|
|
1590
|
-
const parts = completeText.split("\n");
|
|
1591
|
-
parts.pop();
|
|
1592
|
-
return { lines: parts, nextCursor };
|
|
1593
1816
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
function maybeReviveCollector(deps, nextReviveAllowedAt, backoffMs) {
|
|
1600
|
-
if (deps.isCollectorAlive()) return nextReviveAllowedAt;
|
|
1601
|
-
const now = deps.now();
|
|
1602
|
-
if (now < nextReviveAllowedAt) return nextReviveAllowedAt;
|
|
1603
|
-
const log = deps.log ?? console.error;
|
|
1604
|
-
const updatedReviveAllowedAt = now + backoffMs;
|
|
1605
|
-
log("collector was down, restarted it");
|
|
1606
|
-
try {
|
|
1607
|
-
deps.reviveCollector();
|
|
1608
|
-
} catch (err) {
|
|
1609
|
-
log(`collector revive attempt failed: ${err.message}`);
|
|
1610
|
-
}
|
|
1611
|
-
return updatedReviveAllowedAt;
|
|
1817
|
+
function bootoutDaemon(options = {}) {
|
|
1818
|
+
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
1819
|
+
const uid = options.uid ?? currentUid();
|
|
1820
|
+
const result = runLaunchctl(["bootout", `gui/${uid}/${PLIST_LABEL}`]);
|
|
1821
|
+
return result.status === 0;
|
|
1612
1822
|
}
|
|
1613
|
-
function
|
|
1614
|
-
if (result.
|
|
1615
|
-
|
|
1616
|
-
if (result.reason === "no-key") return false;
|
|
1617
|
-
if (result.reason === "http") {
|
|
1618
|
-
return result.status === 429 || result.status >= 500;
|
|
1823
|
+
function parseDaemonPrint(result) {
|
|
1824
|
+
if (result.status !== 0) {
|
|
1825
|
+
return { loaded: false, running: false, pid: null };
|
|
1619
1826
|
}
|
|
1620
|
-
|
|
1827
|
+
const out = result.stdout;
|
|
1828
|
+
const running = /\bstate\s*=\s*running\b/.test(out);
|
|
1829
|
+
const pidMatch = out.match(/\bpid\s*=\s*(\d+)/);
|
|
1830
|
+
const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
|
|
1831
|
+
return { loaded: true, running, pid };
|
|
1621
1832
|
}
|
|
1622
|
-
function
|
|
1623
|
-
|
|
1833
|
+
function queryDaemonStatus(options = {}) {
|
|
1834
|
+
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
1835
|
+
const uid = options.uid ?? currentUid();
|
|
1836
|
+
return parseDaemonPrint(runLaunchctl(["print", `gui/${uid}/${PLIST_LABEL}`]));
|
|
1837
|
+
}
|
|
1838
|
+
function readLastLines(path, maxLines) {
|
|
1839
|
+
if (!existsSync2(path)) return [];
|
|
1624
1840
|
let text;
|
|
1625
1841
|
try {
|
|
1626
|
-
text = readFileSync2(
|
|
1842
|
+
text = readFileSync2(path, "utf8");
|
|
1627
1843
|
} catch {
|
|
1628
1844
|
return [];
|
|
1629
1845
|
}
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
return
|
|
1846
|
+
const lines = text.split("\n");
|
|
1847
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
1848
|
+
return lines.slice(-maxLines);
|
|
1849
|
+
}
|
|
1850
|
+
var ZSHRC_PATH = join(homedir(), ".zshrc");
|
|
1851
|
+
function lineSourcesCccEnv(rawLine) {
|
|
1852
|
+
const trimmed = rawLine.trim();
|
|
1853
|
+
if (!trimmed || trimmed.startsWith("#")) return false;
|
|
1854
|
+
if (!/^(source|\.)\s/.test(trimmed)) return false;
|
|
1855
|
+
return trimmed.includes(".marginfront-ccc/.env");
|
|
1640
1856
|
}
|
|
1641
|
-
function
|
|
1642
|
-
if (!
|
|
1643
|
-
|
|
1857
|
+
function findCccZshrcSourceLines(zshrcPath = ZSHRC_PATH) {
|
|
1858
|
+
if (!existsSync2(zshrcPath)) return { found: false, matchedLines: [] };
|
|
1859
|
+
let text;
|
|
1644
1860
|
try {
|
|
1645
|
-
|
|
1861
|
+
text = readFileSync2(zshrcPath, "utf8");
|
|
1646
1862
|
} catch {
|
|
1863
|
+
return { found: false, matchedLines: [] };
|
|
1647
1864
|
}
|
|
1865
|
+
const matchedLines = text.split("\n").filter(lineSourcesCccEnv).map((line) => line.trim());
|
|
1866
|
+
return { found: matchedLines.length > 0, matchedLines };
|
|
1648
1867
|
}
|
|
1649
|
-
function
|
|
1650
|
-
|
|
1868
|
+
function removeCccZshrcSourceLine({
|
|
1869
|
+
zshrcPath = ZSHRC_PATH,
|
|
1870
|
+
log = console.log
|
|
1871
|
+
} = {}) {
|
|
1872
|
+
if (!existsSync2(zshrcPath)) {
|
|
1873
|
+
log(`Nothing to remove: ${zshrcPath} doesn't exist.`);
|
|
1874
|
+
return { removed: [], backupPath: null };
|
|
1875
|
+
}
|
|
1876
|
+
let text;
|
|
1651
1877
|
try {
|
|
1652
|
-
|
|
1653
|
-
if (existsSync2(queuePath)) rmSync2(queuePath, { force: true });
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
writeFileSync2(
|
|
1657
|
-
queuePath,
|
|
1658
|
-
records.map((r) => JSON.stringify(r) + "\n").join("")
|
|
1659
|
-
);
|
|
1878
|
+
text = readFileSync2(zshrcPath, "utf8");
|
|
1660
1879
|
} catch {
|
|
1661
|
-
|
|
1662
|
-
}
|
|
1663
|
-
function watchAndForward(filePath, options = {}) {
|
|
1664
|
-
const foldCache = options.foldCache === true;
|
|
1665
|
-
const fallbackCustomerId = options.fallbackCustomerId;
|
|
1666
|
-
const cursorPath = options.cursorPath;
|
|
1667
|
-
const pidFilePath = options.pidFilePath;
|
|
1668
|
-
const developerOverride = resolveDeveloperOverride(process2.env);
|
|
1669
|
-
if (!process2.env.MARGINFRONT_API_KEY) {
|
|
1670
|
-
console.error(
|
|
1671
|
-
"MARGINFRONT_API_KEY is not set. Set it in your shell before watching: export MARGINFRONT_API_KEY=..."
|
|
1880
|
+
log(
|
|
1881
|
+
`Could not read ${zshrcPath}, so I left it untouched - remove the \`source ~/.marginfront-ccc/.env\` line by hand if you like.`
|
|
1672
1882
|
);
|
|
1673
|
-
|
|
1674
|
-
}
|
|
1675
|
-
if (pidFilePath) {
|
|
1676
|
-
try {
|
|
1677
|
-
writeFileSync2(pidFilePath, String(process2.pid));
|
|
1678
|
-
} catch {
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
console.log(
|
|
1682
|
-
`Watching ${filePath} for live Claude Code + Codex usage` + (foldCache ? " (fold-cache: cache tokens rolled into input)" : "") + "\u2026 Ctrl-C to stop."
|
|
1683
|
-
);
|
|
1684
|
-
let cursorBytes = readWatchCursor(cursorPath);
|
|
1685
|
-
let running = false;
|
|
1686
|
-
const queuePath = options.queuePath;
|
|
1687
|
-
let queuedCount = readQueue(queuePath).length;
|
|
1688
|
-
let queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
1689
|
-
let nextQueueRetryAt = 0;
|
|
1690
|
-
const hhmmss = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 19);
|
|
1691
|
-
let nextCollectorReviveAt = 0;
|
|
1692
|
-
const collectorHealDeps = {
|
|
1693
|
-
isCollectorAlive: () => isPidAlive(readCollectorPid()),
|
|
1694
|
-
reviveCollector: () => {
|
|
1695
|
-
startCollector();
|
|
1696
|
-
},
|
|
1697
|
-
now: () => Date.now(),
|
|
1698
|
-
log: (message) => console.error(`[${hhmmss()}] ${message}`)
|
|
1699
|
-
};
|
|
1700
|
-
function logSuccess(body, result, stamp) {
|
|
1701
|
-
if (!result.ok) return;
|
|
1702
|
-
for (const rec of body.records) {
|
|
1703
|
-
const row = successRowsFrom(result.parsed).find(
|
|
1704
|
-
(r) => r?.customerExternalId === rec.customerExternalId
|
|
1705
|
-
);
|
|
1706
|
-
const cost = row?.totalCostUsd != null ? `$${row.totalCostUsd}` : "$?";
|
|
1707
|
-
const eventId = row?.eventId ? ` event=${row.eventId}` : "";
|
|
1708
|
-
const reference = "source" in rec.metadata ? `src=${rec.metadata.source}` : `cc=$${rec.metadata.claudeCodeCostUsd ?? "?"}`;
|
|
1709
|
-
const volume = rec.quantity !== void 0 ? `qty=${rec.quantity} tool=${"toolName" in rec.metadata ? rec.metadata.toolName : "?"}` : `in=${rec.inputTokens} out=${rec.outputTokens}`;
|
|
1710
|
-
console.log(
|
|
1711
|
-
`[${stamp}] recorded ${rec.customerExternalId} \xB7 ${volume} \xB7 server=${cost} \xB7 ${reference}${eventId}`
|
|
1712
|
-
);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
function logFailure(result, stamp) {
|
|
1716
|
-
if (result.ok) return;
|
|
1717
|
-
if (result.reason === "http") {
|
|
1718
|
-
console.error(
|
|
1719
|
-
`[${stamp}] MarginFront HTTP ${result.status}: ${result.body}`
|
|
1720
|
-
);
|
|
1721
|
-
} else if (result.reason === "network") {
|
|
1722
|
-
console.error(
|
|
1723
|
-
`[${stamp}] could not reach MarginFront: ${result.message}`
|
|
1724
|
-
);
|
|
1725
|
-
} else {
|
|
1726
|
-
console.error(`[${stamp}] send failed (${result.reason}).`);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
function enqueueFailed(records, stamp) {
|
|
1730
|
-
const room = Math.max(0, MAX_QUEUE_RECORDS - queuedCount);
|
|
1731
|
-
const toQueue = records.slice(0, room);
|
|
1732
|
-
const shed = records.length - toQueue.length;
|
|
1733
|
-
if (toQueue.length > 0) {
|
|
1734
|
-
appendToQueue(queuePath, toQueue);
|
|
1735
|
-
queuedCount += toQueue.length;
|
|
1736
|
-
console.error(
|
|
1737
|
-
`[${stamp}] send failed - queued ${toQueue.length} record(s) for retry (${queuedCount} now in queue).`
|
|
1738
|
-
);
|
|
1739
|
-
}
|
|
1740
|
-
if (shed > 0) {
|
|
1741
|
-
console.error(
|
|
1742
|
-
`[${stamp}] QUEUE FULL (cap ${MAX_QUEUE_RECORDS}): shed ${shed} record(s) - these turns are LOST. The ingest endpoint has been unreachable for a long time; check connectivity.`
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
nextQueueRetryAt = Date.now() + queueBackoffMs;
|
|
1746
|
-
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
1883
|
+
return { removed: [], backupPath: null };
|
|
1747
1884
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
if (queued.length === 0) return;
|
|
1754
|
-
const batch = queued.slice(0, MAX_RECORDS_PER_DRAIN);
|
|
1755
|
-
const result = await postToMarginFront({ records: batch });
|
|
1756
|
-
const stamp = hhmmss();
|
|
1757
|
-
if (result.ok) {
|
|
1758
|
-
const remaining = queued.slice(batch.length);
|
|
1759
|
-
writeQueue(queuePath, remaining);
|
|
1760
|
-
queuedCount = remaining.length;
|
|
1761
|
-
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
1762
|
-
nextQueueRetryAt = 0;
|
|
1763
|
-
console.log(
|
|
1764
|
-
`[${stamp}] queue: ${batch.length} retried record(s) landed \xB7 ${remaining.length} still queued.`
|
|
1765
|
-
);
|
|
1766
|
-
} else if (isRetryablePostResult(result)) {
|
|
1767
|
-
logFailure(result, stamp);
|
|
1768
|
-
const waitMs = queueBackoffMs;
|
|
1769
|
-
nextQueueRetryAt = Date.now() + waitMs;
|
|
1770
|
-
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
1771
|
-
console.error(
|
|
1772
|
-
`[${stamp}] queue: retry failed, ${queued.length} record(s) still queued (next attempt in ~${Math.round(waitMs / 1e3)}s).`
|
|
1773
|
-
);
|
|
1774
|
-
} else {
|
|
1775
|
-
const remaining = queued.slice(batch.length);
|
|
1776
|
-
writeQueue(queuePath, remaining);
|
|
1777
|
-
queuedCount = remaining.length;
|
|
1778
|
-
console.error(
|
|
1779
|
-
`[${stamp}] queue: dropping ${batch.length} record(s) - terminal failure (server rejected them, retrying can't help).`
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1885
|
+
const removed = [];
|
|
1886
|
+
const kept = [];
|
|
1887
|
+
for (const line of text.split("\n")) {
|
|
1888
|
+
if (lineSourcesCccEnv(line)) removed.push(line.trim());
|
|
1889
|
+
else kept.push(line);
|
|
1782
1890
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
try {
|
|
1787
|
-
await drainQueue();
|
|
1788
|
-
const { lines, nextCursor } = readNewCompleteLines(filePath, cursorBytes);
|
|
1789
|
-
for (const rawLine of lines) {
|
|
1790
|
-
const line = rawLine.trim();
|
|
1791
|
-
if (!line) continue;
|
|
1792
|
-
let parsed;
|
|
1793
|
-
try {
|
|
1794
|
-
parsed = JSON.parse(line);
|
|
1795
|
-
} catch {
|
|
1796
|
-
console.error("(skipped one telemetry line that wasn't valid JSON)");
|
|
1797
|
-
continue;
|
|
1798
|
-
}
|
|
1799
|
-
const body = buildRequestBodyForLine(parsed, {
|
|
1800
|
-
foldCache,
|
|
1801
|
-
fallbackCustomerId,
|
|
1802
|
-
developerOverride,
|
|
1803
|
-
rawSourceLine: line
|
|
1804
|
-
});
|
|
1805
|
-
if (!body.records.length) continue;
|
|
1806
|
-
const result = await postToMarginFront(body);
|
|
1807
|
-
const stamp = hhmmss();
|
|
1808
|
-
if (!result.ok) {
|
|
1809
|
-
logFailure(result, stamp);
|
|
1810
|
-
if (queuePath && isRetryablePostResult(result)) {
|
|
1811
|
-
enqueueFailed(body.records, stamp);
|
|
1812
|
-
} else if (queuePath) {
|
|
1813
|
-
console.error(
|
|
1814
|
-
`[${stamp}] dropping ${body.records.length} record(s): terminal send failure (not retryable).`
|
|
1815
|
-
);
|
|
1816
|
-
}
|
|
1817
|
-
continue;
|
|
1818
|
-
}
|
|
1819
|
-
logSuccess(body, result, stamp);
|
|
1820
|
-
if (queuedCount > 0) {
|
|
1821
|
-
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
1822
|
-
nextQueueRetryAt = 0;
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
if (nextCursor !== cursorBytes) {
|
|
1826
|
-
cursorBytes = nextCursor;
|
|
1827
|
-
writeWatchCursor(cursorPath, cursorBytes);
|
|
1828
|
-
}
|
|
1829
|
-
} finally {
|
|
1830
|
-
running = false;
|
|
1831
|
-
}
|
|
1891
|
+
if (removed.length === 0) {
|
|
1892
|
+
log(`Nothing to remove in ${zshrcPath}: no MarginFront source line found.`);
|
|
1893
|
+
return { removed: [], backupPath: null };
|
|
1832
1894
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1895
|
+
const backupPath = `${zshrcPath}.ccc-bak`;
|
|
1896
|
+
copyFileSync(zshrcPath, backupPath);
|
|
1897
|
+
writeFileSync2(zshrcPath, kept.join("\n"));
|
|
1898
|
+
log(
|
|
1899
|
+
`Removed the redundant 'source ~/.marginfront-ccc/.env' line from ${zshrcPath} (backup at ${backupPath}).`
|
|
1835
1900
|
);
|
|
1836
|
-
|
|
1837
|
-
nextCollectorReviveAt = maybeReviveCollector(
|
|
1838
|
-
collectorHealDeps,
|
|
1839
|
-
nextCollectorReviveAt,
|
|
1840
|
-
COLLECTOR_REVIVE_BACKOFF_MS
|
|
1841
|
-
);
|
|
1842
|
-
processNewLines().catch(
|
|
1843
|
-
(e) => console.error(`watch error: ${e.message}`)
|
|
1844
|
-
);
|
|
1845
|
-
}, WATCH_POLL_MS);
|
|
1846
|
-
return function stop() {
|
|
1847
|
-
clearInterval(timer);
|
|
1848
|
-
if (pidFilePath) {
|
|
1849
|
-
try {
|
|
1850
|
-
rmSync2(pidFilePath, { force: true });
|
|
1851
|
-
} catch {
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
};
|
|
1901
|
+
return { removed, backupPath };
|
|
1855
1902
|
}
|
|
1856
1903
|
|
|
1857
1904
|
// src/cli.ts
|
|
@@ -2268,8 +2315,10 @@ async function cmdStart(deps = {}) {
|
|
|
2268
2315
|
errorLog(
|
|
2269
2316
|
[
|
|
2270
2317
|
"",
|
|
2271
|
-
|
|
2272
|
-
|
|
2318
|
+
// Pin @latest so a stale npx cache can't run an old build that hides the real
|
|
2319
|
+
// version (or a command this build added).
|
|
2320
|
+
`Check again anytime: npx ${PKG_NAME}@latest status`,
|
|
2321
|
+
`Re-check your MarginFront key (mf_sk_...) with: npx ${PKG_NAME}@latest init (then run start again)`
|
|
2273
2322
|
].join("\n")
|
|
2274
2323
|
);
|
|
2275
2324
|
return 1;
|
|
@@ -2299,6 +2348,10 @@ function printDaemonLogTail(label, path) {
|
|
|
2299
2348
|
for (const line of lines) console.log(` ${line}`);
|
|
2300
2349
|
}
|
|
2301
2350
|
function cmdStatus() {
|
|
2351
|
+
console.log(`CCC version ${readVersion()}.`);
|
|
2352
|
+
console.log(
|
|
2353
|
+
` (If a command looks missing, re-run pinned to the latest: npx ${PKG_NAME}@latest status)`
|
|
2354
|
+
);
|
|
2302
2355
|
const status = queryDaemonStatus();
|
|
2303
2356
|
if (!status.loaded) {
|
|
2304
2357
|
console.log(
|
|
@@ -2329,9 +2382,12 @@ function cmdPreview(positionals, foldCache) {
|
|
|
2329
2382
|
return 1;
|
|
2330
2383
|
}
|
|
2331
2384
|
const parsedOtlp = readAndParseOtlpFile(inputFilePath);
|
|
2385
|
+
loadEnvFile();
|
|
2386
|
+
const developerOverride = resolveDeveloperOverride(process3.env);
|
|
2332
2387
|
const body = buildRequestBodyForLine(parsedOtlp, {
|
|
2333
2388
|
foldCache,
|
|
2334
|
-
fallbackCustomerId: NO_IDENTITY_CUSTOMER
|
|
2389
|
+
fallbackCustomerId: NO_IDENTITY_CUSTOMER,
|
|
2390
|
+
developerOverride
|
|
2335
2391
|
});
|
|
2336
2392
|
console.log(JSON.stringify(body, null, 2));
|
|
2337
2393
|
const usedFallback = body.records.some(
|
|
@@ -2482,12 +2538,31 @@ async function cmdStop() {
|
|
|
2482
2538
|
}
|
|
2483
2539
|
return 0;
|
|
2484
2540
|
}
|
|
2485
|
-
function cmdUninstall(purge, deps = {}) {
|
|
2541
|
+
async function cmdUninstall(purge, deps = {}) {
|
|
2486
2542
|
const log = deps.log ?? console.log;
|
|
2487
2543
|
const removeDaemon = deps.uninstallDaemon ?? (() => uninstallDaemon({ log }));
|
|
2488
2544
|
const stopColl = deps.stopCollector ?? (() => {
|
|
2489
2545
|
stopCollector({ log });
|
|
2490
2546
|
});
|
|
2547
|
+
const readForwarderPid = deps.readForwarderPid ?? (() => {
|
|
2548
|
+
if (!existsSync3(FORWARDER_PID_PATH)) return null;
|
|
2549
|
+
try {
|
|
2550
|
+
const raw = readFileSync3(FORWARDER_PID_PATH, "utf8").trim();
|
|
2551
|
+
const parsed = parseInt(raw, 10);
|
|
2552
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
2553
|
+
} catch {
|
|
2554
|
+
return null;
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
const isForwarderAlive = deps.isForwarderAlive ?? ((pid) => isPidAlive(pid));
|
|
2558
|
+
const signalForwarder = deps.signalForwarder ?? ((pid, signal) => {
|
|
2559
|
+
try {
|
|
2560
|
+
process3.kill(pid, signal);
|
|
2561
|
+
} catch {
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2565
|
+
const now = deps.now ?? (() => Date.now());
|
|
2491
2566
|
const removeClaude = deps.removeClaudeTelemetry ?? (() => removeClaudeTelemetrySettings({ log }));
|
|
2492
2567
|
const removeCodex = deps.removeCodexConfig ?? (() => removeCodexOtelConfig({ log }));
|
|
2493
2568
|
const removeFile = deps.removeRuntimeFile ?? ((path) => {
|
|
@@ -2500,7 +2575,23 @@ function cmdUninstall(purge, deps = {}) {
|
|
|
2500
2575
|
const findZshrc = deps.findZshrcLines ?? (() => findCccZshrcSourceLines());
|
|
2501
2576
|
const unsetDesktop = deps.unsetDesktopEnv ?? (() => unsetDesktopGuiEnv());
|
|
2502
2577
|
removeDaemon();
|
|
2503
|
-
|
|
2578
|
+
const forwarderPid = readForwarderPid();
|
|
2579
|
+
await stopForwarderThenStopCollector(
|
|
2580
|
+
{
|
|
2581
|
+
isForwarderAlive: () => forwarderPid !== null && isForwarderAlive(forwarderPid),
|
|
2582
|
+
signalForwarder: (signal) => {
|
|
2583
|
+
if (forwarderPid !== null) signalForwarder(forwarderPid, signal);
|
|
2584
|
+
},
|
|
2585
|
+
stopCollector: () => {
|
|
2586
|
+
stopColl();
|
|
2587
|
+
},
|
|
2588
|
+
sleep,
|
|
2589
|
+
now,
|
|
2590
|
+
log
|
|
2591
|
+
},
|
|
2592
|
+
STOP_FORWARDER_POLL_MS,
|
|
2593
|
+
STOP_FORWARDER_TIMEOUT_MS
|
|
2594
|
+
);
|
|
2504
2595
|
removeClaude();
|
|
2505
2596
|
removeCodex();
|
|
2506
2597
|
unsetDesktop();
|
|
@@ -2581,7 +2672,7 @@ async function main() {
|
|
|
2581
2672
|
case "stop":
|
|
2582
2673
|
return cmdStop();
|
|
2583
2674
|
case "uninstall":
|
|
2584
|
-
return cmdUninstall(flags.has("--purge"));
|
|
2675
|
+
return await cmdUninstall(flags.has("--purge"));
|
|
2585
2676
|
default:
|
|
2586
2677
|
console.error(`Unknown command "${command}".
|
|
2587
2678
|
`);
|