@marginfront/code-cost-clarity 0.5.4 → 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/README.md +62 -21
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +1848 -1636
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -8,1801 +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 process2 from "process";
|
|
26
|
+
|
|
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";
|
|
25
41
|
import process from "process";
|
|
26
|
-
var
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
);
|
|
42
|
+
var _cccRequire = createRequire(import.meta.url);
|
|
43
|
+
function resolveCccPackageVersion() {
|
|
44
|
+
if ("0.6.1".length > 0) {
|
|
45
|
+
return "0.6.1";
|
|
47
46
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
# YOUR MarginFront SECRET key (mf_sk_*). Create or copy one at:
|
|
106
|
-
# app.marginfront.com -> Build -> API Keys -> "Create Key Pair"
|
|
107
|
-
# (https://app.marginfront.com/developer-zone/api-keys)
|
|
108
|
-
# Use the SECRET key (mf_sk_*) - a publishable key (mf_pk_*) is rejected on send.
|
|
109
|
-
# The forwarder reads this from the environment only; it is never hardcoded.
|
|
110
|
-
# Leave blank to run in no-identity preview mode (see the README).
|
|
111
|
-
MARGINFRONT_API_KEY=
|
|
112
|
-
`;
|
|
113
|
-
}
|
|
114
|
-
function renderCollectorYaml(jsonlPath = LIVE_JSONL_PATH) {
|
|
115
|
-
return `# OpenTelemetry Collector config - Claude Code -> MarginFront
|
|
116
|
-
# ---------------------------------------------------------------------------
|
|
117
|
-
# WHAT THIS DOES, plainly: it's the "catcher." Claude Code broadcasts its token
|
|
118
|
-
# usage here; this collector batches what it hears and writes it to a file,
|
|
119
|
-
# which the forwarder tails and turns into MarginFront usage rows.
|
|
120
|
-
# ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
receivers:
|
|
123
|
-
# The door Claude Code knocks on. It listens for OpenTelemetry (OTLP) on the
|
|
124
|
-
# two standard local ports. We point Claude Code at HTTP/protobuf on 4318 -
|
|
125
|
-
# gRPC on 4317 silently failed to connect from Claude Code 2.1.185 in testing,
|
|
126
|
-
# so 4318 is the one that actually works. Bound to localhost only.
|
|
127
|
-
otlp:
|
|
128
|
-
protocols:
|
|
129
|
-
grpc:
|
|
130
|
-
endpoint: 127.0.0.1:4317
|
|
131
|
-
http:
|
|
132
|
-
endpoint: 127.0.0.1:4318
|
|
133
|
-
|
|
134
|
-
processors:
|
|
135
|
-
# THE NO-DOUBLE-COUNT PROCESSOR. Claude Code emits CUMULATIVE counters (each
|
|
136
|
-
# export is the running total since the session started), and it ignores the
|
|
137
|
-
# "give me deltas" env var. Without this, the forwarder would record the
|
|
138
|
-
# running total every export - re-counting everything before it. This turns
|
|
139
|
-
# each cumulative total into the increment since the last export.
|
|
140
|
-
cumulativetodelta: {}
|
|
141
|
-
|
|
142
|
-
# Group what arrives into small batches and flush quickly, so the live view
|
|
143
|
-
# feels near-real-time rather than trickling one datapoint at a time.
|
|
144
|
-
batch:
|
|
145
|
-
timeout: 1s
|
|
146
|
-
|
|
147
|
-
exporters:
|
|
148
|
-
# Write each batch as one line of OTLP/JSON (JSON Lines). The forwarder
|
|
149
|
-
# watches this file and records each new line. Both pipelines below share this
|
|
150
|
-
# one exporter, so Claude Code's metrics and Codex's logs land in the SAME file
|
|
151
|
-
# (each as its own line) and the one forwarder reads both.
|
|
152
|
-
file:
|
|
153
|
-
path: ${jsonlPath}
|
|
154
|
-
|
|
155
|
-
service:
|
|
156
|
-
telemetry:
|
|
157
|
-
logs:
|
|
158
|
-
# The collector's OWN log verbosity (how chatty otelcol itself is). This is
|
|
159
|
-
# NOT a data pipeline - it does not catch Codex's usage logs. The pipeline
|
|
160
|
-
# that does is service.pipelines.logs below. Keep this quiet.
|
|
161
|
-
level: warn
|
|
162
|
-
pipelines:
|
|
163
|
-
# Claude Code source: token + cost COUNTERS arrive as metrics.
|
|
164
|
-
# Receive -> de-dupe (cumulative->delta) -> batch -> file.
|
|
165
|
-
metrics:
|
|
166
|
-
receivers: [otlp]
|
|
167
|
-
processors: [cumulativetodelta, batch]
|
|
168
|
-
exporters: [file]
|
|
169
|
-
# Codex source (LOWAI-463): Codex reports usage on the LOG stream, not as
|
|
170
|
-
# metrics, so it needs its own pipeline. No cumulativetodelta here - that's a
|
|
171
|
-
# METRICS processor and doesn't apply to logs; each Codex log record is one
|
|
172
|
-
# turn's usage on its own. Receive -> batch -> file (the same file).
|
|
173
|
-
logs:
|
|
174
|
-
receivers: [otlp]
|
|
175
|
-
processors: [batch]
|
|
176
|
-
exporters: [file]
|
|
177
|
-
`;
|
|
178
|
-
}
|
|
179
|
-
var CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
180
|
-
var CODEX_CCC_BEGIN_MARKER = "# >>> MarginFront code-cost-clarity - auto-added; `npx @marginfront/code-cost-clarity uninstall` removes this block >>>";
|
|
181
|
-
var CODEX_CCC_END_MARKER = "# <<< MarginFront code-cost-clarity - end of auto-added block <<<";
|
|
182
|
-
var CODEX_OTEL_TOML_BLOCK = [
|
|
183
|
-
"[otel]",
|
|
184
|
-
'exporter = { otlp-http = { endpoint = "http://127.0.0.1:4318/v1/logs", protocol = "binary" } }'
|
|
185
|
-
].join("\n");
|
|
186
|
-
function renderCodexCccBlock() {
|
|
187
|
-
return [
|
|
188
|
-
CODEX_CCC_BEGIN_MARKER,
|
|
189
|
-
CODEX_OTEL_TOML_BLOCK,
|
|
190
|
-
CODEX_CCC_END_MARKER
|
|
191
|
-
].join("\n");
|
|
192
|
-
}
|
|
193
|
-
function renderCodexConfigInstructions() {
|
|
194
|
-
return [
|
|
195
|
-
"Optional - also capture Codex (in addition to, or instead of, Claude Code):",
|
|
196
|
-
"",
|
|
197
|
-
" Codex reports its usage to the SAME local collector, so there's nothing",
|
|
198
|
-
" extra to install. Add this block to your ~/.codex/config.toml:",
|
|
199
|
-
"",
|
|
200
|
-
// Single-sourced from CODEX_OTEL_TOML_BLOCK above, indented for the printout,
|
|
201
|
-
// so the pasted block always matches what the auto-writer would write.
|
|
202
|
-
...CODEX_OTEL_TOML_BLOCK.split("\n").map((line) => " " + line),
|
|
203
|
-
"",
|
|
204
|
-
" Then run Codex normally. `run` already watches both sources - Claude Code,",
|
|
205
|
-
" Codex, or both - from the one collector file; there's no extra flag.",
|
|
206
|
-
"",
|
|
207
|
-
" Note: exact [otel] key names can vary by Codex version, and whether your",
|
|
208
|
-
" engineer email shows up depends on how you sign in (org API key vs ChatGPT",
|
|
209
|
-
" login). If the email doesn't surface, usage still lands under the no-identity",
|
|
210
|
-
" placeholder. See the README's Codex section for the full details."
|
|
211
|
-
].join("\n");
|
|
212
|
-
}
|
|
213
|
-
function lineDeclaresOtelTable(rawLine) {
|
|
214
|
-
const beforeComment = rawLine.split("#")[0].trim();
|
|
215
|
-
if (!beforeComment) return false;
|
|
216
|
-
let firstToken;
|
|
217
|
-
if (beforeComment.startsWith("[")) {
|
|
218
|
-
const inner = beforeComment.replace(/^\[+/, "").trim();
|
|
219
|
-
const path = inner.split("]")[0].trim();
|
|
220
|
-
firstToken = path.split(".")[0].trim();
|
|
221
|
-
} else {
|
|
222
|
-
firstToken = beforeComment.split(/[.=\s]/)[0].trim();
|
|
47
|
+
try {
|
|
48
|
+
const pkg = _cccRequire("../package.json");
|
|
49
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
50
|
+
return pkg.version;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
223
53
|
}
|
|
224
|
-
|
|
225
|
-
return firstToken === "otel";
|
|
226
|
-
}
|
|
227
|
-
function codexConfigHasOtelTable(content) {
|
|
228
|
-
return content.split(/\r?\n/).some(lineDeclaresOtelTable);
|
|
54
|
+
return "0.0.0-unknown";
|
|
229
55
|
}
|
|
230
|
-
|
|
231
|
-
|
|
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;
|
|
232
60
|
try {
|
|
233
|
-
|
|
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;
|
|
234
65
|
} catch {
|
|
235
|
-
return false;
|
|
236
66
|
}
|
|
67
|
+
console.error(
|
|
68
|
+
"Ignoring MARGINFRONT_INGEST_URL - only an https://*.marginfront.com URL is allowed; sending to production instead."
|
|
69
|
+
);
|
|
70
|
+
return DEFAULT_INGEST_URL;
|
|
237
71
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
72
|
+
var MARGINFRONT_INGEST_URL = resolveIngestUrl();
|
|
73
|
+
function resolveDeveloperOverride(env) {
|
|
74
|
+
return (env.CCC_DEVELOPER_EMAIL || "").trim() || void 0;
|
|
75
|
+
}
|
|
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
|
+
}
|
|
260
138
|
}
|
|
261
|
-
|
|
262
|
-
copyFileSync(configPath, backupPath);
|
|
263
|
-
const separator = content.length > 0 ? "\n" : "";
|
|
264
|
-
writeFileSync(configPath, content + separator + renderCodexCccBlock() + "\n");
|
|
265
|
-
log(
|
|
266
|
-
`Added the MarginFront telemetry block to ${configPath} (backup at ${backupPath}).`
|
|
267
|
-
);
|
|
268
|
-
return { action: "appended", backupPath };
|
|
139
|
+
return lookup;
|
|
269
140
|
}
|
|
270
|
-
function
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
log(
|
|
276
|
-
`Nothing to undo: ${configPath} doesn't exist (Codex was never wired up here).`
|
|
277
|
-
);
|
|
278
|
-
return { action: "missing", backupPath: null };
|
|
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;
|
|
279
146
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
284
|
-
log(
|
|
285
|
-
`Nothing to undo: ${configPath} has no MarginFront block (left every other setting untouched).`
|
|
286
|
-
);
|
|
287
|
-
return { action: "no-marker", backupPath: null };
|
|
147
|
+
if (dataPoint.asDouble !== void 0 && dataPoint.asDouble !== null) {
|
|
148
|
+
const parsed = Math.round(Number(dataPoint.asDouble));
|
|
149
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
288
150
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|
|
171
|
+
}
|
|
294
172
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
173
|
+
return collectedDataPoints;
|
|
174
|
+
}
|
|
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"
|
|
299
181
|
);
|
|
300
|
-
return
|
|
182
|
+
return dottedVersion;
|
|
301
183
|
}
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const cpu = archMap[nodeArch];
|
|
307
|
-
if (!os) {
|
|
308
|
-
throw new Error(
|
|
309
|
-
`Unsupported operating system "${nodePlatform}". The bundled collector supports macOS (darwin) and Linux. On Windows, run this under WSL.`
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
if (!cpu) {
|
|
313
|
-
throw new Error(
|
|
314
|
-
`Unsupported CPU architecture "${nodeArch}". The bundled collector supports arm64 and x64 (amd64).`
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
const versionNoV = version.replace(/^v/, "");
|
|
318
|
-
const asset = `otelcol-contrib_${versionNoV}_${os}_${cpu}.tar.gz`;
|
|
319
|
-
const url = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/${version}/${asset}`;
|
|
320
|
-
return { os, cpu, asset, url, version };
|
|
184
|
+
function idempotencyKeyFor(rawSourceLine, email, rawModel, sessionId, indexWithinLine) {
|
|
185
|
+
return createHash("sha256").update(
|
|
186
|
+
`${rawSourceLine}|${email}|${rawModel}|${sessionId ?? ""}|${indexWithinLine}`
|
|
187
|
+
).digest("hex");
|
|
321
188
|
}
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
);
|
|
330
|
-
return COLLECTOR_BIN_PATH;
|
|
331
|
-
}
|
|
332
|
-
const { asset, url, version } = resolveCollectorAsset();
|
|
333
|
-
const tgzPath = join(tmpdir(), asset);
|
|
334
|
-
log(
|
|
335
|
-
`Downloading the collector (${asset}, ~360 MB) - this can take a minute\u2026`
|
|
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"
|
|
336
196
|
);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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}`;
|
|
345
206
|
}
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
);
|
|
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);
|
|
228
|
+
}
|
|
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;
|
|
237
|
+
}
|
|
354
238
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
}
|
|
360
246
|
}
|
|
361
|
-
|
|
362
|
-
`Collector integrity check FAILED - refusing to install.
|
|
363
|
-
What happened: the collector we downloaded for "${asset}" does NOT match its known-good fingerprint, so we deleted it and stopped.
|
|
364
|
-
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.
|
|
365
|
-
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.`
|
|
366
|
-
);
|
|
247
|
+
return null;
|
|
367
248
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
379
261
|
);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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;
|
|
384
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);
|
|
385
301
|
}
|
|
386
|
-
|
|
387
|
-
execFileSync("chmod", ["755", COLLECTOR_BIN_PATH]);
|
|
388
|
-
} catch {
|
|
389
|
-
}
|
|
390
|
-
log(`Collector installed at ${COLLECTOR_BIN_PATH}.`);
|
|
391
|
-
return COLLECTOR_BIN_PATH;
|
|
302
|
+
return { records };
|
|
392
303
|
}
|
|
393
|
-
function
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (existsSync(ENV_PATH)) {
|
|
401
|
-
log(`Kept your existing settings file at ${ENV_PATH} (API key preserved).`);
|
|
402
|
-
} else {
|
|
403
|
-
writeFileSync(ENV_PATH, renderEnvFile(), { mode: 384 });
|
|
404
|
-
log(`Wrote settings template to ${ENV_PATH} - add your API key there.`);
|
|
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
|
+
}
|
|
405
311
|
}
|
|
406
|
-
|
|
407
|
-
log(`Wrote collector config to ${COLLECTOR_CONFIG_PATH}.`);
|
|
312
|
+
return lookup;
|
|
408
313
|
}
|
|
409
|
-
function
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
314
|
+
function logAttrString(value) {
|
|
315
|
+
if (value && typeof value.stringValue === "string") return value.stringValue;
|
|
316
|
+
return void 0;
|
|
317
|
+
}
|
|
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;
|
|
417
323
|
}
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
const eq = line.indexOf("=");
|
|
422
|
-
if (eq === -1) continue;
|
|
423
|
-
const key = line.slice(0, eq).trim();
|
|
424
|
-
let value = line.slice(eq + 1).trim();
|
|
425
|
-
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
426
|
-
value = value.slice(1, -1);
|
|
427
|
-
}
|
|
428
|
-
if (!key) continue;
|
|
429
|
-
loaded[key] = value;
|
|
430
|
-
if (process.env[key] === void 0) {
|
|
431
|
-
process.env[key] = value;
|
|
432
|
-
}
|
|
324
|
+
if (value.doubleValue !== void 0 && value.doubleValue !== null) {
|
|
325
|
+
const parsed = Math.round(Number(value.doubleValue));
|
|
326
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
433
327
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const current = existsSync(path) ? readFileSync(path, "utf8") : renderEnvFile();
|
|
438
|
-
const keyLine = /^MARGINFRONT_API_KEY=.*$/m;
|
|
439
|
-
let next;
|
|
440
|
-
if (keyLine.test(current)) {
|
|
441
|
-
next = current.replace(keyLine, () => `MARGINFRONT_API_KEY=${value}`);
|
|
442
|
-
} else {
|
|
443
|
-
const sep = current.endsWith("\n") || current === "" ? "" : "\n";
|
|
444
|
-
next = `${current}${sep}MARGINFRONT_API_KEY=${value}
|
|
445
|
-
`;
|
|
328
|
+
if (typeof value.stringValue === "string") {
|
|
329
|
+
const parsed = parseInt(value.stringValue, 10);
|
|
330
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
446
331
|
}
|
|
447
|
-
|
|
448
|
-
chmodSync(path, 384);
|
|
449
|
-
}
|
|
450
|
-
var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
451
|
-
var CLAUDE_SETTINGS_OWNERSHIP_PATH = join(
|
|
452
|
-
CONFIG_DIR,
|
|
453
|
-
"claude-settings-owned.json"
|
|
454
|
-
);
|
|
455
|
-
var CCC_OWNED_TELEMETRY_KEYS = [
|
|
456
|
-
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
457
|
-
"OTEL_METRICS_EXPORTER",
|
|
458
|
-
"OTEL_LOGS_EXPORTER",
|
|
459
|
-
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
460
|
-
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
461
|
-
"OTEL_METRIC_EXPORT_INTERVAL"
|
|
462
|
-
];
|
|
463
|
-
var CCC_TELEMETRY_VALUES = {
|
|
464
|
-
// Turns on Claude Code's usage broadcasting.
|
|
465
|
-
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
466
|
-
// Send token usage as OpenTelemetry metrics.
|
|
467
|
-
OTEL_METRICS_EXPORTER: "otlp",
|
|
468
|
-
// Send the per-tool-call EVENT stream too (needed for tool-call line items).
|
|
469
|
-
OTEL_LOGS_EXPORTER: "otlp",
|
|
470
|
-
// HTTP/protobuf - verified working; gRPC on 4317 silently failed in testing.
|
|
471
|
-
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
|
|
472
|
-
// Point Claude Code at the local collector (must match the collector YAML).
|
|
473
|
-
OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318",
|
|
474
|
-
// Flush usage every 5 minutes (batched ~one event per turn).
|
|
475
|
-
OTEL_METRIC_EXPORT_INTERVAL: "300000"
|
|
476
|
-
};
|
|
477
|
-
function isPlainObject(value) {
|
|
478
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
332
|
+
return 0;
|
|
479
333
|
}
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return backupPath;
|
|
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);
|
|
484
337
|
}
|
|
485
|
-
function
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
410
|
}
|
|
496
|
-
return out;
|
|
497
|
-
} catch {
|
|
498
|
-
return null;
|
|
499
411
|
}
|
|
412
|
+
return { records };
|
|
500
413
|
}
|
|
501
|
-
function
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
|
|
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";
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
508
421
|
}
|
|
509
|
-
function
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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;
|
|
469
|
+
}
|
|
537
470
|
}
|
|
538
|
-
settings = parsed;
|
|
539
|
-
}
|
|
540
|
-
if ("env" in settings && !isPlainObject(settings.env)) {
|
|
541
|
-
const backupPath2 = backupClaudeSettings(settingsPath);
|
|
542
|
-
throw new Error(
|
|
543
|
-
`Could not update ${settingsPath}.
|
|
544
|
-
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.
|
|
545
|
-
What we did: saved an untouched copy at ${backupPath2}.
|
|
546
|
-
Fix: change "env" to a normal object like { "KEY": "value" } (or remove it), then run init again.`
|
|
547
|
-
);
|
|
548
471
|
}
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
|
571
494
|
}
|
|
572
|
-
}
|
|
573
|
-
|
|
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
|
+
);
|
|
574
504
|
}
|
|
505
|
+
records.push(record);
|
|
575
506
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
);
|
|
585
|
-
} else {
|
|
586
|
-
log(`No changes needed in ${settingsPath} - telemetry already in place.`);
|
|
587
|
-
}
|
|
588
|
-
writeClaudeSettingsOwnership(ownershipPath, ownedKeys);
|
|
589
|
-
if (skipped.length) {
|
|
590
|
-
log(
|
|
591
|
-
`Left ${skipped.length} setting(s) you already had in place untouched: ` + skipped.join(", ")
|
|
592
|
-
);
|
|
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] };
|
|
593
515
|
}
|
|
594
|
-
return
|
|
516
|
+
return buildMarginFrontRequestBody(parsedOtlp, options);
|
|
595
517
|
}
|
|
596
|
-
function
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
} = {}) {
|
|
601
|
-
const removed = [];
|
|
602
|
-
const kept = [];
|
|
603
|
-
const owned = readClaudeSettingsOwnership(ownershipPath);
|
|
604
|
-
if (owned === null) {
|
|
605
|
-
log(
|
|
606
|
-
`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.`
|
|
607
522
|
);
|
|
608
|
-
|
|
523
|
+
process.exit(1);
|
|
609
524
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
525
|
+
let fileText;
|
|
526
|
+
try {
|
|
527
|
+
fileText = readFileSync(inputFilePath, "utf8");
|
|
528
|
+
} catch (readError) {
|
|
529
|
+
console.error(
|
|
530
|
+
`Could not read/parse ${inputFilePath}: ${readError.message}`
|
|
613
531
|
);
|
|
614
|
-
|
|
615
|
-
return { removed, kept, backupPath: null };
|
|
532
|
+
process.exit(1);
|
|
616
533
|
}
|
|
617
|
-
let parsed;
|
|
618
534
|
try {
|
|
619
|
-
|
|
535
|
+
return JSON.parse(fileText);
|
|
620
536
|
} catch {
|
|
621
|
-
|
|
622
|
-
`Could not
|
|
623
|
-
);
|
|
624
|
-
return { removed, kept, backupPath: null };
|
|
625
|
-
}
|
|
626
|
-
if (!isPlainObject(parsed)) {
|
|
627
|
-
log(
|
|
628
|
-
`Could not undo cleanly: ${settingsPath} isn't a JSON object, so we left it untouched.`
|
|
629
|
-
);
|
|
630
|
-
return { removed, kept, backupPath: null };
|
|
631
|
-
}
|
|
632
|
-
const settings = parsed;
|
|
633
|
-
const env = isPlainObject(settings.env) ? settings.env : null;
|
|
634
|
-
let mutated = false;
|
|
635
|
-
for (const [key, canonical] of Object.entries(owned)) {
|
|
636
|
-
if (env && env[key] === canonical) {
|
|
637
|
-
delete env[key];
|
|
638
|
-
removed.push(key);
|
|
639
|
-
mutated = true;
|
|
640
|
-
} else {
|
|
641
|
-
kept.push(key);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
if (env && Object.keys(env).length === 0) {
|
|
645
|
-
delete settings.env;
|
|
646
|
-
mutated = true;
|
|
647
|
-
}
|
|
648
|
-
let backupPath = null;
|
|
649
|
-
if (mutated) {
|
|
650
|
-
backupPath = backupClaudeSettings(settingsPath);
|
|
651
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
652
|
-
log(
|
|
653
|
-
`Removed ${removed.length} MarginFront telemetry setting(s) from ${settingsPath} (backup at ${backupPath}).`
|
|
654
|
-
);
|
|
655
|
-
} else {
|
|
656
|
-
log(
|
|
657
|
-
`Nothing to remove from ${settingsPath} - the telemetry keys were already gone or you'd changed them (left untouched).`
|
|
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.`
|
|
658
539
|
);
|
|
540
|
+
process.exit(1);
|
|
659
541
|
}
|
|
660
|
-
rmSync(ownershipPath, { force: true });
|
|
661
|
-
return { removed, kept, backupPath };
|
|
662
542
|
}
|
|
663
|
-
function
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
return true;
|
|
668
|
-
} catch (err) {
|
|
669
|
-
return err?.code === "EPERM";
|
|
543
|
+
async function postToMarginFront(requestBody) {
|
|
544
|
+
const apiKey = process.env.MARGINFRONT_API_KEY;
|
|
545
|
+
if (!apiKey) {
|
|
546
|
+
return { ok: false, reason: "no-key" };
|
|
670
547
|
}
|
|
671
|
-
|
|
672
|
-
function readCollectorPid() {
|
|
673
|
-
if (!existsSync(COLLECTOR_PID_PATH)) return null;
|
|
548
|
+
let response;
|
|
674
549
|
try {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
+
};
|
|
691
570
|
}
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
log(`Started collector (pid ${child.pid}); logs at ${COLLECTOR_LOG_PATH}.`);
|
|
701
|
-
return { pid: child.pid, startedByUs: true };
|
|
702
|
-
}
|
|
703
|
-
function stopCollector({
|
|
704
|
-
log = console.log
|
|
705
|
-
} = {}) {
|
|
706
|
-
const pid = readCollectorPid();
|
|
707
|
-
if (!isPidAlive(pid)) {
|
|
708
|
-
if (existsSync(COLLECTOR_PID_PATH))
|
|
709
|
-
rmSync(COLLECTOR_PID_PATH, { force: true });
|
|
710
|
-
log("No running collector found.");
|
|
711
|
-
return false;
|
|
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
|
+
};
|
|
712
579
|
}
|
|
580
|
+
let parsed = null;
|
|
713
581
|
try {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
log(`Could not stop collector (pid ${pid}): ${err.message}`);
|
|
582
|
+
parsed = JSON.parse(responseText);
|
|
583
|
+
} catch {
|
|
584
|
+
parsed = null;
|
|
718
585
|
}
|
|
719
|
-
|
|
720
|
-
return true;
|
|
586
|
+
return { ok: true, parsed, body: responseText };
|
|
721
587
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
"Library",
|
|
726
|
-
"LaunchAgents",
|
|
727
|
-
PLIST_LABEL + ".plist"
|
|
728
|
-
);
|
|
729
|
-
var DAEMON_CLI_PATH = join(CONFIG_DIR, "cli.js");
|
|
730
|
-
var DAEMON_STDOUT_LOG_PATH = join(CONFIG_DIR, "forwarder.out.log");
|
|
731
|
-
var DAEMON_STDERR_LOG_PATH = join(CONFIG_DIR, "forwarder.err.log");
|
|
732
|
-
function defaultLaunchctlRunner(args) {
|
|
733
|
-
const result = spawnSync("launchctl", args, { encoding: "utf8" });
|
|
734
|
-
return {
|
|
735
|
-
// A spawn error (e.g. launchctl missing on a non-mac) leaves status null; we
|
|
736
|
-
// treat that as a failure so callers don't read it as "succeeded."
|
|
737
|
-
status: typeof result.status === "number" ? result.status : 1,
|
|
738
|
-
stdout: result.stdout ?? "",
|
|
739
|
-
stderr: result.stderr ?? ""
|
|
740
|
-
};
|
|
588
|
+
function successRowsFrom(parsed) {
|
|
589
|
+
const rows = parsed?.results?.success;
|
|
590
|
+
return Array.isArray(rows) ? rows : [];
|
|
741
591
|
}
|
|
742
|
-
function
|
|
743
|
-
|
|
744
|
-
|
|
592
|
+
function readWatchCursor(cursorPath) {
|
|
593
|
+
if (!cursorPath || !existsSync(cursorPath)) return 0;
|
|
594
|
+
try {
|
|
595
|
+
const parsed = parseInt(readFileSync(cursorPath, "utf8").trim(), 10);
|
|
596
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
597
|
+
} catch {
|
|
598
|
+
return 0;
|
|
745
599
|
}
|
|
746
600
|
}
|
|
747
|
-
function
|
|
748
|
-
|
|
749
|
-
|
|
601
|
+
function writeWatchCursor(cursorPath, byteOffset) {
|
|
602
|
+
if (!cursorPath) return;
|
|
603
|
+
try {
|
|
604
|
+
writeFileSync(cursorPath, String(byteOffset));
|
|
605
|
+
} catch {
|
|
750
606
|
}
|
|
751
607
|
}
|
|
752
|
-
function
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
nodePath,
|
|
760
|
-
cliPath
|
|
761
|
-
}) {
|
|
762
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
763
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
764
|
-
<plist version="1.0">
|
|
765
|
-
<dict>
|
|
766
|
-
<key>Label</key>
|
|
767
|
-
<string>${escapeXml(PLIST_LABEL)}</string>
|
|
768
|
-
<key>ProgramArguments</key>
|
|
769
|
-
<array>
|
|
770
|
-
<string>${escapeXml(nodePath)}</string>
|
|
771
|
-
<string>${escapeXml(cliPath)}</string>
|
|
772
|
-
<string>run</string>
|
|
773
|
-
</array>
|
|
774
|
-
<key>WorkingDirectory</key>
|
|
775
|
-
<string>${escapeXml(CONFIG_DIR)}</string>
|
|
776
|
-
<key>RunAtLoad</key>
|
|
777
|
-
<true/>
|
|
778
|
-
<key>KeepAlive</key>
|
|
779
|
-
<true/>
|
|
780
|
-
<key>StandardOutPath</key>
|
|
781
|
-
<string>${escapeXml(DAEMON_STDOUT_LOG_PATH)}</string>
|
|
782
|
-
<key>StandardErrorPath</key>
|
|
783
|
-
<string>${escapeXml(DAEMON_STDERR_LOG_PATH)}</string>
|
|
784
|
-
</dict>
|
|
785
|
-
</plist>
|
|
786
|
-
`;
|
|
787
|
-
}
|
|
788
|
-
function installDaemon(options = {}) {
|
|
789
|
-
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
790
|
-
const log = options.log ?? console.log;
|
|
791
|
-
const nodePath = options.nodePath ?? process.execPath;
|
|
792
|
-
const sourceCliPath = options.sourceCliPath ?? fileURLToPath(import.meta.url);
|
|
793
|
-
const daemonCliPath = options.daemonCliPath ?? DAEMON_CLI_PATH;
|
|
794
|
-
const plistPath = options.plistPath ?? PLIST_PATH;
|
|
795
|
-
const uid = options.uid ?? currentUid();
|
|
796
|
-
if (nodePath === "npx" || nodePath.endsWith("/npx")) {
|
|
797
|
-
throw new Error(
|
|
798
|
-
"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."
|
|
799
|
-
);
|
|
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 };
|
|
800
615
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
let reloaded = false;
|
|
816
|
-
if (alreadyLoaded) {
|
|
817
|
-
log(
|
|
818
|
-
"A background meter is already loaded - reloading it with the new settings."
|
|
819
|
-
);
|
|
820
|
-
runLaunchctl(["bootout", serviceTarget]);
|
|
821
|
-
reloaded = true;
|
|
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);
|
|
822
630
|
}
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
What happened: macOS refused to load the job at ${plistPath}.
|
|
828
|
-
` + (bootstrap.stderr ? `launchctl said: ${bootstrap.stderr.trim()}
|
|
829
|
-
` : "") + `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.`
|
|
830
|
-
);
|
|
631
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
632
|
+
const lastNewline = chunk.lastIndexOf(10);
|
|
633
|
+
if (lastNewline === -1) {
|
|
634
|
+
return { lines: [], nextCursor: start };
|
|
831
635
|
}
|
|
832
|
-
|
|
833
|
-
|
|
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 };
|
|
834
641
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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");
|
|
654
|
+
try {
|
|
655
|
+
deps.reviveCollector();
|
|
656
|
+
} catch (err) {
|
|
657
|
+
log(`collector revive attempt failed: ${err.message}`);
|
|
849
658
|
}
|
|
659
|
+
return updatedReviveAllowedAt;
|
|
850
660
|
}
|
|
851
|
-
function
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
function parseDaemonPrint(result) {
|
|
858
|
-
if (result.status !== 0) {
|
|
859
|
-
return { loaded: false, running: false, pid: null };
|
|
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;
|
|
860
667
|
}
|
|
861
|
-
|
|
862
|
-
const running = /\bstate\s*=\s*running\b/.test(out);
|
|
863
|
-
const pidMatch = out.match(/\bpid\s*=\s*(\d+)/);
|
|
864
|
-
const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
|
|
865
|
-
return { loaded: true, running, pid };
|
|
866
|
-
}
|
|
867
|
-
function queryDaemonStatus(options = {}) {
|
|
868
|
-
const runLaunchctl = options.runLaunchctl ?? defaultLaunchctlRunner;
|
|
869
|
-
const uid = options.uid ?? currentUid();
|
|
870
|
-
return parseDaemonPrint(runLaunchctl(["print", `gui/${uid}/${PLIST_LABEL}`]));
|
|
668
|
+
return false;
|
|
871
669
|
}
|
|
872
|
-
function
|
|
873
|
-
if (!existsSync(
|
|
670
|
+
function readQueue(queuePath) {
|
|
671
|
+
if (!queuePath || !existsSync(queuePath)) return [];
|
|
874
672
|
let text;
|
|
875
673
|
try {
|
|
876
|
-
text = readFileSync(
|
|
674
|
+
text = readFileSync(queuePath, "utf8");
|
|
877
675
|
} catch {
|
|
878
676
|
return [];
|
|
879
677
|
}
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (!/^(source|\.)\s/.test(trimmed)) return false;
|
|
889
|
-
return trimmed.includes(".marginfront-ccc/.env");
|
|
890
|
-
}
|
|
891
|
-
function findCccZshrcSourceLines(zshrcPath = ZSHRC_PATH) {
|
|
892
|
-
if (!existsSync(zshrcPath)) return { found: false, matchedLines: [] };
|
|
893
|
-
let text;
|
|
894
|
-
try {
|
|
895
|
-
text = readFileSync(zshrcPath, "utf8");
|
|
896
|
-
} catch {
|
|
897
|
-
return { found: false, matchedLines: [] };
|
|
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
|
+
}
|
|
898
686
|
}
|
|
899
|
-
|
|
900
|
-
return { found: matchedLines.length > 0, matchedLines };
|
|
687
|
+
return out;
|
|
901
688
|
}
|
|
902
|
-
function
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
} = {}) {
|
|
906
|
-
if (!existsSync(zshrcPath)) {
|
|
907
|
-
log(`Nothing to remove: ${zshrcPath} doesn't exist.`);
|
|
908
|
-
return { removed: [], backupPath: null };
|
|
909
|
-
}
|
|
910
|
-
let text;
|
|
689
|
+
function appendToQueue(queuePath, records) {
|
|
690
|
+
if (!queuePath || records.length === 0) return;
|
|
691
|
+
const payload = records.map((r) => JSON.stringify(r) + "\n").join("");
|
|
911
692
|
try {
|
|
912
|
-
|
|
693
|
+
appendFileSync(queuePath, payload);
|
|
913
694
|
} catch {
|
|
914
|
-
log(
|
|
915
|
-
`Could not read ${zshrcPath}, so I left it untouched - remove the \`source ~/.marginfront-ccc/.env\` line by hand if you like.`
|
|
916
|
-
);
|
|
917
|
-
return { removed: [], backupPath: null };
|
|
918
|
-
}
|
|
919
|
-
const removed = [];
|
|
920
|
-
const kept = [];
|
|
921
|
-
for (const line of text.split("\n")) {
|
|
922
|
-
if (lineSourcesCccEnv(line)) removed.push(line.trim());
|
|
923
|
-
else kept.push(line);
|
|
924
|
-
}
|
|
925
|
-
if (removed.length === 0) {
|
|
926
|
-
log(`Nothing to remove in ${zshrcPath}: no MarginFront source line found.`);
|
|
927
|
-
return { removed: [], backupPath: null };
|
|
928
695
|
}
|
|
929
|
-
const backupPath = `${zshrcPath}.ccc-bak`;
|
|
930
|
-
copyFileSync(zshrcPath, backupPath);
|
|
931
|
-
writeFileSync(zshrcPath, kept.join("\n"));
|
|
932
|
-
log(
|
|
933
|
-
`Removed the redundant 'source ~/.marginfront-ccc/.env' line from ${zshrcPath} (backup at ${backupPath}).`
|
|
934
|
-
);
|
|
935
|
-
return { removed, backupPath };
|
|
936
696
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
import {
|
|
940
|
-
readFileSync as readFileSync2,
|
|
941
|
-
existsSync as existsSync2,
|
|
942
|
-
statSync,
|
|
943
|
-
openSync as openSync2,
|
|
944
|
-
readSync,
|
|
945
|
-
closeSync,
|
|
946
|
-
writeFileSync as writeFileSync2,
|
|
947
|
-
appendFileSync,
|
|
948
|
-
rmSync as rmSync2
|
|
949
|
-
} from "fs";
|
|
950
|
-
import { createHash as createHash2 } from "crypto";
|
|
951
|
-
import { createRequire } from "module";
|
|
952
|
-
import process2 from "process";
|
|
953
|
-
var _cccRequire = createRequire(import.meta.url);
|
|
954
|
-
function resolveCccPackageVersion() {
|
|
955
|
-
if ("0.5.4".length > 0) {
|
|
956
|
-
return "0.5.4";
|
|
957
|
-
}
|
|
697
|
+
function writeQueue(queuePath, records) {
|
|
698
|
+
if (!queuePath) return;
|
|
958
699
|
try {
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
return
|
|
700
|
+
if (records.length === 0) {
|
|
701
|
+
if (existsSync(queuePath)) rmSync(queuePath, { force: true });
|
|
702
|
+
return;
|
|
962
703
|
}
|
|
704
|
+
writeFileSync(
|
|
705
|
+
queuePath,
|
|
706
|
+
records.map((r) => JSON.stringify(r) + "\n").join("")
|
|
707
|
+
);
|
|
963
708
|
} catch {
|
|
964
709
|
}
|
|
965
|
-
return "0.0.0-unknown";
|
|
966
710
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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);
|
|
977
722
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
var MARGINFRONT_INGEST_URL = resolveIngestUrl();
|
|
984
|
-
var AGENT_CODE = "claude-code";
|
|
985
|
-
var SIGNAL_NAME = "claude-code-turn";
|
|
986
|
-
var MODEL_PROVIDER = "anthropic";
|
|
987
|
-
var AGENT_CODE_CODEX = "codex";
|
|
988
|
-
var SIGNAL_NAME_CODEX = "codex-turn";
|
|
989
|
-
var MODEL_PROVIDER_CODEX = "openai";
|
|
990
|
-
var CODEX_USAGE_EVENT_NAME = "codex.sse_event";
|
|
991
|
-
var CODEX_INPUT_TOKEN_KEY = "input_token_count";
|
|
992
|
-
var CODEX_OUTPUT_TOKEN_KEY = "output_token_count";
|
|
993
|
-
var CODEX_CACHED_TOKEN_KEY = "cached_token_count";
|
|
994
|
-
var CODEX_REASONING_TOKEN_KEY = "reasoning_token_count";
|
|
995
|
-
var CODEX_TOOL_TOKEN_KEY = "tool_token_count";
|
|
996
|
-
var CODEX_TOKEN_KEYS = [
|
|
997
|
-
CODEX_INPUT_TOKEN_KEY,
|
|
998
|
-
CODEX_OUTPUT_TOKEN_KEY,
|
|
999
|
-
CODEX_CACHED_TOKEN_KEY,
|
|
1000
|
-
CODEX_REASONING_TOKEN_KEY
|
|
1001
|
-
];
|
|
1002
|
-
var CODEX_EXCLUDED_MODELS = /* @__PURE__ */ new Set(["codex-auto-review"]);
|
|
1003
|
-
var SIGNAL_NAME_TOOL = "claude-code-tool";
|
|
1004
|
-
var SIGNAL_NAME_TOOL_CODEX = "codex-tool";
|
|
1005
|
-
var CLAUDE_TOOL_RESULT_EVENT = "claude_code.tool_result";
|
|
1006
|
-
var CODEX_TOOL_RESULT_EVENT = "codex.tool_result";
|
|
1007
|
-
var TOOL_NAME_KEYS = ["tool_name", "tool.name"];
|
|
1008
|
-
var MODEL_PROVIDER_TOOL = "tool";
|
|
1009
|
-
var BUILTIN_NONBILLABLE_TOOLS = /* @__PURE__ */ new Set([
|
|
1010
|
-
// Claude Code built-ins
|
|
1011
|
-
"Read",
|
|
1012
|
-
"Edit",
|
|
1013
|
-
"Write",
|
|
1014
|
-
"MultiEdit",
|
|
1015
|
-
"NotebookEdit",
|
|
1016
|
-
"NotebookRead",
|
|
1017
|
-
"Bash",
|
|
1018
|
-
"BashOutput",
|
|
1019
|
-
"KillShell",
|
|
1020
|
-
"KillBash",
|
|
1021
|
-
"Grep",
|
|
1022
|
-
"Glob",
|
|
1023
|
-
"LS",
|
|
1024
|
-
"TodoWrite",
|
|
1025
|
-
"ExitPlanMode",
|
|
1026
|
-
"Task",
|
|
1027
|
-
"Agent",
|
|
1028
|
-
// alternate name for Task - hedge against the rename
|
|
1029
|
-
"SlashCommand",
|
|
1030
|
-
"ToolSearch",
|
|
1031
|
-
// Codex built-ins (free + high-volume)
|
|
1032
|
-
"exec_command",
|
|
1033
|
-
"apply_patch"
|
|
1034
|
-
]);
|
|
1035
|
-
var ENVIRONMENT = "development";
|
|
1036
|
-
var WATCH_POLL_MS = 1e3;
|
|
1037
|
-
var NO_IDENTITY_CUSTOMER = "claude-code-no-identity";
|
|
1038
|
-
var CODEX_NO_IDENTITY_CUSTOMER = "codex-no-identity";
|
|
1039
|
-
function attributesToLookup(attributeList) {
|
|
1040
|
-
const lookup = {};
|
|
1041
|
-
if (!Array.isArray(attributeList)) return lookup;
|
|
1042
|
-
for (const attribute of attributeList) {
|
|
1043
|
-
if (attribute && typeof attribute.key === "string" && attribute.value) {
|
|
1044
|
-
lookup[attribute.key] = attribute.value.stringValue;
|
|
723
|
+
if (pidFilePath) {
|
|
724
|
+
try {
|
|
725
|
+
writeFileSync(pidFilePath, String(process.pid));
|
|
726
|
+
} catch {
|
|
1045
727
|
}
|
|
1046
728
|
}
|
|
1047
|
-
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
+
}
|
|
1058
762
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
for (const metric of metricsList) {
|
|
1072
|
-
if (metric?.name !== metricName) continue;
|
|
1073
|
-
const dataPointsList = metric?.sum?.dataPoints;
|
|
1074
|
-
if (!Array.isArray(dataPointsList)) continue;
|
|
1075
|
-
for (const dataPoint of dataPointsList) {
|
|
1076
|
-
collectedDataPoints.push(dataPoint);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
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}).`);
|
|
1079
775
|
}
|
|
1080
776
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
}
|
|
1092
|
-
function idempotencyKeyFor(rawSourceLine, email, rawModel, sessionId, indexWithinLine) {
|
|
1093
|
-
return createHash2("sha256").update(
|
|
1094
|
-
`${rawSourceLine}|${email}|${rawModel}|${sessionId ?? ""}|${indexWithinLine}`
|
|
1095
|
-
).digest("hex");
|
|
1096
|
-
}
|
|
1097
|
-
function buildMarginFrontRequestBody(parsedOtlp, options = {}) {
|
|
1098
|
-
const foldCache = options.foldCache === true;
|
|
1099
|
-
const fallbackCustomerId = typeof options.fallbackCustomerId === "string" && options.fallbackCustomerId.length > 0 ? options.fallbackCustomerId : null;
|
|
1100
|
-
const tokenDataPoints = collectDataPointsForMetric(
|
|
1101
|
-
parsedOtlp,
|
|
1102
|
-
"claude_code.token.usage"
|
|
1103
|
-
);
|
|
1104
|
-
const costDataPoints = collectDataPointsForMetric(
|
|
1105
|
-
parsedOtlp,
|
|
1106
|
-
"claude_code.cost.usage"
|
|
1107
|
-
);
|
|
1108
|
-
const groupsByKey = /* @__PURE__ */ new Map();
|
|
1109
|
-
function makeGroupKey(email, model, sessionId) {
|
|
1110
|
-
return `${email}
|
|
1111
|
-
${model}
|
|
1112
|
-
${sessionId}`;
|
|
1113
|
-
}
|
|
1114
|
-
for (const dataPoint of tokenDataPoints) {
|
|
1115
|
-
const attributes = attributesToLookup(dataPoint.attributes);
|
|
1116
|
-
const email = attributes["user.email"] || fallbackCustomerId;
|
|
1117
|
-
const rawModel = attributes["model"];
|
|
1118
|
-
const sessionId = attributes["session.id"];
|
|
1119
|
-
const tokenType = attributes["type"];
|
|
1120
|
-
const tokenCount = dataPointTokenCount(dataPoint);
|
|
1121
|
-
if (!email || !rawModel || !sessionId) continue;
|
|
1122
|
-
const groupKey = makeGroupKey(email, rawModel, sessionId);
|
|
1123
|
-
let group = groupsByKey.get(groupKey);
|
|
1124
|
-
if (!group) {
|
|
1125
|
-
group = {
|
|
1126
|
-
email,
|
|
1127
|
-
rawModel,
|
|
1128
|
-
sessionId,
|
|
1129
|
-
inputTokens: 0,
|
|
1130
|
-
outputTokens: 0,
|
|
1131
|
-
cacheReadTokens: 0,
|
|
1132
|
-
cacheCreationTokens: 0
|
|
1133
|
-
};
|
|
1134
|
-
groupsByKey.set(groupKey, group);
|
|
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
|
+
);
|
|
1135
787
|
}
|
|
1136
|
-
if (
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
} else if (tokenType === "cacheRead") {
|
|
1141
|
-
group.cacheReadTokens += tokenCount;
|
|
1142
|
-
} else if (tokenType === "cacheCreation") {
|
|
1143
|
-
group.cacheCreationTokens += tokenCount;
|
|
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
|
+
);
|
|
1144
792
|
}
|
|
793
|
+
nextQueueRetryAt = Date.now() + queueBackoffMs;
|
|
794
|
+
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
1145
795
|
}
|
|
1146
|
-
function
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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);
|
|
1152
876
|
}
|
|
877
|
+
} finally {
|
|
878
|
+
running = false;
|
|
1153
879
|
}
|
|
1154
|
-
return null;
|
|
1155
880
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const claudeCodeCostUsd = findCostForGroup(
|
|
1165
|
-
group.email,
|
|
1166
|
-
group.rawModel,
|
|
1167
|
-
group.sessionId
|
|
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
|
|
1168
889
|
);
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
environment: ENVIRONMENT,
|
|
1180
|
-
// usageDate omitted so MarginFront defaults it to "now."
|
|
1181
|
-
metadata: {
|
|
1182
|
-
sessionId: group.sessionId,
|
|
1183
|
-
rawModel: group.rawModel,
|
|
1184
|
-
// Raw cache numbers ALWAYS recorded (audit), whether typed or folded.
|
|
1185
|
-
cacheReadTokens,
|
|
1186
|
-
cacheCreationTokens,
|
|
1187
|
-
// Whether inputTokens includes cache (fold) or not (default).
|
|
1188
|
-
cacheFoldedIntoInput: foldCache,
|
|
1189
|
-
// Claude Code's own cache-accurate cost - reconciliation ground truth,
|
|
1190
|
-
// not the billed figure.
|
|
1191
|
-
claudeCodeCostUsd
|
|
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 {
|
|
1192
900
|
}
|
|
1193
|
-
};
|
|
1194
|
-
if (!foldCache) {
|
|
1195
|
-
record.cacheReadTokens = cacheReadTokens;
|
|
1196
|
-
record.cacheWriteTokens = cacheCreationTokens;
|
|
1197
|
-
}
|
|
1198
|
-
if (options.rawSourceLine !== void 0) {
|
|
1199
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1200
|
-
options.rawSourceLine,
|
|
1201
|
-
group.email,
|
|
1202
|
-
group.rawModel,
|
|
1203
|
-
group.sessionId,
|
|
1204
|
-
records.length
|
|
1205
|
-
);
|
|
1206
901
|
}
|
|
1207
|
-
|
|
1208
|
-
}
|
|
1209
|
-
return { records };
|
|
902
|
+
};
|
|
1210
903
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
+
);
|
|
1218
927
|
}
|
|
1219
|
-
return
|
|
928
|
+
return override;
|
|
1220
929
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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();
|
|
1224
940
|
}
|
|
1225
|
-
function
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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=
|
|
1007
|
+
`;
|
|
1008
|
+
}
|
|
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.`
|
|
1142
|
+
);
|
|
1143
|
+
return { action: "created", backupPath: null };
|
|
1144
|
+
}
|
|
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");
|
|
1160
|
+
log(
|
|
1161
|
+
`Added the MarginFront telemetry block to ${configPath} (backup at ${backupPath}).`
|
|
1162
|
+
);
|
|
1163
|
+
return { action: "appended", backupPath };
|
|
1164
|
+
}
|
|
1165
|
+
function removeCodexOtelConfig({
|
|
1166
|
+
configPath = CODEX_CONFIG_PATH,
|
|
1167
|
+
log = console.log
|
|
1168
|
+
} = {}) {
|
|
1169
|
+
if (!existsSync2(configPath)) {
|
|
1170
|
+
log(
|
|
1171
|
+
`Nothing to undo: ${configPath} doesn't exist (Codex was never wired up here).`
|
|
1172
|
+
);
|
|
1173
|
+
return { action: "missing", backupPath: null };
|
|
1174
|
+
}
|
|
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).`
|
|
1181
|
+
);
|
|
1182
|
+
return { action: "no-marker", backupPath: null };
|
|
1183
|
+
}
|
|
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 };
|
|
1196
|
+
}
|
|
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
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
if (!cpu) {
|
|
1208
|
+
throw new Error(
|
|
1209
|
+
`Unsupported CPU architecture "${nodeArch}". The bundled collector supports arm64 and x64 (amd64).`
|
|
1210
|
+
);
|
|
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 };
|
|
1216
|
+
}
|
|
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;
|
|
1226
|
+
}
|
|
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
|
+
);
|
|
1232
|
+
try {
|
|
1233
|
+
execFileSync("curl", ["-fSL", "-o", tgzPath, url], { stdio: "inherit" });
|
|
1234
|
+
} catch {
|
|
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
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
try {
|
|
1282
|
+
execFileSync("chmod", ["755", COLLECTOR_BIN_PATH]);
|
|
1283
|
+
} catch {
|
|
1284
|
+
}
|
|
1285
|
+
log(`Collector installed at ${COLLECTOR_BIN_PATH}.`);
|
|
1286
|
+
return COLLECTOR_BIN_PATH;
|
|
1287
|
+
}
|
|
1288
|
+
function ensureConfigFiles({
|
|
1289
|
+
log = console.log
|
|
1290
|
+
} = {}) {
|
|
1291
|
+
if (!existsSync2(CONFIG_DIR)) {
|
|
1292
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1293
|
+
log(`Created ${CONFIG_DIR}.`);
|
|
1230
1294
|
}
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
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.`);
|
|
1234
1300
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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 = {};
|
|
1307
|
+
let text;
|
|
1308
|
+
try {
|
|
1309
|
+
text = readFileSync2(path, "utf8");
|
|
1310
|
+
} catch {
|
|
1311
|
+
return {};
|
|
1238
1312
|
}
|
|
1239
|
-
|
|
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
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return loaded;
|
|
1240
1330
|
}
|
|
1241
|
-
function
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
+
`;
|
|
1341
|
+
}
|
|
1342
|
+
writeFileSync2(path, next, { mode: 384 });
|
|
1343
|
+
chmodSync(path, 384);
|
|
1244
1344
|
}
|
|
1245
|
-
function
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const logRecordsList = scopeLog?.logRecords;
|
|
1256
|
-
if (!Array.isArray(logRecordsList)) continue;
|
|
1257
|
-
for (const logRecord of logRecordsList) {
|
|
1258
|
-
const lookup = logAttributesToLookup(logRecord?.attributes);
|
|
1259
|
-
const eventName = logAttrString(lookup["event.name"]) ?? logAttrString(logRecord?.body);
|
|
1260
|
-
if (toolResultSource(eventName) !== null) continue;
|
|
1261
|
-
if (!isCodexUsageRecord(lookup, eventName)) continue;
|
|
1262
|
-
const rawModel = logAttrString(lookup["model"]);
|
|
1263
|
-
if (!rawModel) continue;
|
|
1264
|
-
if (CODEX_EXCLUDED_MODELS.has(rawModel.trim().toLowerCase())) continue;
|
|
1265
|
-
const email = logAttrString(lookup["user.email"]) || fallbackCustomerId;
|
|
1266
|
-
if (!email) continue;
|
|
1267
|
-
const sessionId = logAttrString(lookup["session.id"]) ?? logAttrString(lookup["conversation.id"]) ?? logAttrString(lookup["session_id"]) ?? null;
|
|
1268
|
-
const inputCount = logAttrTokenCount(lookup[CODEX_INPUT_TOKEN_KEY]);
|
|
1269
|
-
const outputCount = logAttrTokenCount(lookup[CODEX_OUTPUT_TOKEN_KEY]);
|
|
1270
|
-
const cachedCount = logAttrTokenCount(lookup[CODEX_CACHED_TOKEN_KEY]);
|
|
1271
|
-
const reasoningCount = logAttrTokenCount(
|
|
1272
|
-
lookup[CODEX_REASONING_TOKEN_KEY]
|
|
1273
|
-
);
|
|
1274
|
-
const toolCount = logAttrTokenCount(lookup[CODEX_TOOL_TOKEN_KEY]);
|
|
1275
|
-
if (inputCount === 0 && outputCount === 0 && cachedCount === 0)
|
|
1276
|
-
continue;
|
|
1277
|
-
const freshInputTokens = Math.max(0, inputCount - cachedCount);
|
|
1278
|
-
const billedInputTokens = foldCache ? inputCount : freshInputTokens;
|
|
1279
|
-
const record = {
|
|
1280
|
-
customerExternalId: email,
|
|
1281
|
-
agentCode: AGENT_CODE_CODEX,
|
|
1282
|
-
signalName: SIGNAL_NAME_CODEX,
|
|
1283
|
-
model: normalizeModelId(rawModel),
|
|
1284
|
-
modelProvider: MODEL_PROVIDER_CODEX,
|
|
1285
|
-
inputTokens: billedInputTokens,
|
|
1286
|
-
// Reasoning is ALREADY inside this number - do NOT add it again.
|
|
1287
|
-
outputTokens: outputCount,
|
|
1288
|
-
environment: ENVIRONMENT,
|
|
1289
|
-
// usageDate omitted so MarginFront defaults it to "now."
|
|
1290
|
-
metadata: {
|
|
1291
|
-
source: "codex",
|
|
1292
|
-
sessionId,
|
|
1293
|
-
rawModel,
|
|
1294
|
-
// Audit-only - nested inside input/output already, never added to the
|
|
1295
|
-
// billed tokens; recorded to prove we didn't drop anything.
|
|
1296
|
-
cachedTokens: cachedCount,
|
|
1297
|
-
reasoningTokens: reasoningCount,
|
|
1298
|
-
toolTokens: toolCount,
|
|
1299
|
-
cacheFoldedIntoInput: foldCache
|
|
1300
|
-
}
|
|
1301
|
-
};
|
|
1302
|
-
if (!foldCache) {
|
|
1303
|
-
record.cacheReadTokens = cachedCount;
|
|
1304
|
-
}
|
|
1305
|
-
if (options.rawSourceLine !== void 0) {
|
|
1306
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1307
|
-
options.rawSourceLine,
|
|
1308
|
-
email,
|
|
1309
|
-
rawModel,
|
|
1310
|
-
sessionId,
|
|
1311
|
-
records.length
|
|
1312
|
-
);
|
|
1313
|
-
}
|
|
1314
|
-
records.push(record);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1345
|
+
function gitGlobalUserEmail() {
|
|
1346
|
+
try {
|
|
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();
|
|
1353
|
+
} catch {
|
|
1354
|
+
return "";
|
|
1317
1355
|
}
|
|
1318
|
-
return { records };
|
|
1319
1356
|
}
|
|
1320
|
-
function
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
+
`;
|
|
1325
1367
|
}
|
|
1326
|
-
|
|
1368
|
+
writeFileSync2(path, next, { mode: 384 });
|
|
1369
|
+
chmodSync(path, 384);
|
|
1327
1370
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
count: 0
|
|
1370
|
-
};
|
|
1371
|
-
groups.set(groupKey, group);
|
|
1372
|
-
}
|
|
1373
|
-
group.count += 1;
|
|
1374
|
-
}
|
|
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;
|
|
1408
|
+
try {
|
|
1409
|
+
const parsed = JSON.parse(readFileSync2(ownershipPath, "utf8"));
|
|
1410
|
+
if (!isPlainObject(parsed) || !isPlainObject(parsed.ownedKeys)) {
|
|
1411
|
+
return null;
|
|
1375
1412
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
const isCodex = group.telemetrySource === "codex";
|
|
1380
|
-
const record = {
|
|
1381
|
-
customerExternalId: group.email,
|
|
1382
|
-
agentCode: isCodex ? AGENT_CODE_CODEX : AGENT_CODE,
|
|
1383
|
-
signalName: isCodex ? SIGNAL_NAME_TOOL_CODEX : SIGNAL_NAME_TOOL,
|
|
1384
|
-
// Raw tool NAME as the service id, verbatim - NOT through normalizeModelId
|
|
1385
|
-
// (that's Claude-LLM-name-specific). Catalog matches (model=tool name,
|
|
1386
|
-
// modelProvider="tool").
|
|
1387
|
-
model: group.toolName,
|
|
1388
|
-
modelProvider: MODEL_PROVIDER_TOOL,
|
|
1389
|
-
// Billing volume = times this tool fired this flush; no token fields (not an
|
|
1390
|
-
// LLM turn). Server prices quantity x unitPrice.
|
|
1391
|
-
quantity: group.count,
|
|
1392
|
-
environment: ENVIRONMENT,
|
|
1393
|
-
metadata: {
|
|
1394
|
-
source: "tool",
|
|
1395
|
-
sessionId: group.sessionId,
|
|
1396
|
-
toolName: group.toolName,
|
|
1397
|
-
telemetrySource: group.telemetrySource,
|
|
1398
|
-
estimated: true
|
|
1399
|
-
}
|
|
1400
|
-
};
|
|
1401
|
-
if (options.rawSourceLine !== void 0) {
|
|
1402
|
-
record.idempotencyKey = idempotencyKeyFor(
|
|
1403
|
-
options.rawSourceLine,
|
|
1404
|
-
group.email,
|
|
1405
|
-
`tool:${group.toolName}`,
|
|
1406
|
-
group.sessionId,
|
|
1407
|
-
records.length
|
|
1408
|
-
);
|
|
1413
|
+
const out = {};
|
|
1414
|
+
for (const [key, value] of Object.entries(parsed.ownedKeys)) {
|
|
1415
|
+
out[key] = String(value);
|
|
1409
1416
|
}
|
|
1410
|
-
|
|
1417
|
+
return out;
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1411
1420
|
}
|
|
1412
|
-
return { records };
|
|
1413
1421
|
}
|
|
1414
|
-
function
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
return buildMarginFrontRequestBody(parsedOtlp, options);
|
|
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");
|
|
1422
1429
|
}
|
|
1423
|
-
function
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
settings = parsed;
|
|
1460
|
+
}
|
|
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.`
|
|
1427
1468
|
);
|
|
1428
|
-
process2.exit(1);
|
|
1429
1469
|
}
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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);
|
|
1492
|
+
}
|
|
1493
|
+
} else {
|
|
1494
|
+
skipped.push(key);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
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}).` : ".")
|
|
1436
1505
|
);
|
|
1437
|
-
|
|
1506
|
+
} else {
|
|
1507
|
+
log(`No changes needed in ${settingsPath} - telemetry already in place.`);
|
|
1438
1508
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
`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.`
|
|
1509
|
+
writeClaudeSettingsOwnership(ownershipPath, ownedKeys);
|
|
1510
|
+
if (skipped.length) {
|
|
1511
|
+
log(
|
|
1512
|
+
`Left ${skipped.length} setting(s) you already had in place untouched: ` + skipped.join(", ")
|
|
1444
1513
|
);
|
|
1445
|
-
process2.exit(1);
|
|
1446
1514
|
}
|
|
1515
|
+
return { written, skipped, backupPath };
|
|
1447
1516
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
// This User-Agent is what lets the server tag these events as source='ccc'
|
|
1462
|
-
// instead of the generic 'api' fallback. Without it, PostHog dashboards
|
|
1463
|
-
// can't distinguish "developer paying for AI tokens via CCC" traffic from
|
|
1464
|
-
// raw API clients - every CCC event would be mislabelled as source='api'.
|
|
1465
|
-
"User-Agent": `@marginfront/code-cost-clarity/${CCC_PACKAGE_VERSION}`
|
|
1466
|
-
},
|
|
1467
|
-
body: JSON.stringify(requestBody)
|
|
1468
|
-
});
|
|
1469
|
-
} catch (networkError) {
|
|
1470
|
-
return {
|
|
1471
|
-
ok: false,
|
|
1472
|
-
reason: "network",
|
|
1473
|
-
message: networkError.message
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
const responseText = await response.text();
|
|
1477
|
-
if (!response.ok) {
|
|
1478
|
-
return {
|
|
1479
|
-
ok: false,
|
|
1480
|
-
reason: "http",
|
|
1481
|
-
status: response.status,
|
|
1482
|
-
body: responseText
|
|
1483
|
-
};
|
|
1484
|
-
}
|
|
1485
|
-
let parsed = null;
|
|
1486
|
-
try {
|
|
1487
|
-
parsed = JSON.parse(responseText);
|
|
1488
|
-
} catch {
|
|
1489
|
-
parsed = null;
|
|
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 };
|
|
1490
1530
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
}
|
|
1497
|
-
function readWatchCursor(cursorPath) {
|
|
1498
|
-
if (!cursorPath || !existsSync2(cursorPath)) return 0;
|
|
1499
|
-
try {
|
|
1500
|
-
const parsed = parseInt(readFileSync2(cursorPath, "utf8").trim(), 10);
|
|
1501
|
-
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
1502
|
-
} catch {
|
|
1503
|
-
return 0;
|
|
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 };
|
|
1504
1537
|
}
|
|
1505
|
-
|
|
1506
|
-
function writeWatchCursor(cursorPath, byteOffset) {
|
|
1507
|
-
if (!cursorPath) return;
|
|
1538
|
+
let parsed;
|
|
1508
1539
|
try {
|
|
1509
|
-
|
|
1540
|
+
parsed = JSON.parse(readFileSync2(settingsPath, "utf8"));
|
|
1510
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 };
|
|
1511
1546
|
}
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
size = statSync(filePath).size;
|
|
1518
|
-
} catch {
|
|
1519
|
-
return { lines: [], nextCursor: fromByte };
|
|
1547
|
+
if (!isPlainObject(parsed)) {
|
|
1548
|
+
log(
|
|
1549
|
+
`Could not undo cleanly: ${settingsPath} isn't a JSON object, so we left it untouched.`
|
|
1550
|
+
);
|
|
1551
|
+
return { removed, kept, backupPath: null };
|
|
1520
1552
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
const
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
return { lines: [], nextCursor: fromByte };
|
|
1533
|
-
} finally {
|
|
1534
|
-
if (fd !== void 0) closeSync(fd);
|
|
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);
|
|
1563
|
+
}
|
|
1535
1564
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
return { lines: [], nextCursor: start };
|
|
1565
|
+
if (env && Object.keys(env).length === 0) {
|
|
1566
|
+
delete settings.env;
|
|
1567
|
+
mutated = true;
|
|
1540
1568
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
function maybeReviveCollector(deps, nextReviveAllowedAt, backoffMs) {
|
|
1553
|
-
if (deps.isCollectorAlive()) return nextReviveAllowedAt;
|
|
1554
|
-
const now = deps.now();
|
|
1555
|
-
if (now < nextReviveAllowedAt) return nextReviveAllowedAt;
|
|
1556
|
-
const log = deps.log ?? console.error;
|
|
1557
|
-
const updatedReviveAllowedAt = now + backoffMs;
|
|
1558
|
-
log("collector was down, restarted it");
|
|
1559
|
-
try {
|
|
1560
|
-
deps.reviveCollector();
|
|
1561
|
-
} catch (err) {
|
|
1562
|
-
log(`collector revive attempt failed: ${err.message}`);
|
|
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
|
+
);
|
|
1563
1580
|
}
|
|
1564
|
-
|
|
1581
|
+
rmSync2(ownershipPath, { force: true });
|
|
1582
|
+
return { removed, kept, backupPath };
|
|
1565
1583
|
}
|
|
1566
|
-
function
|
|
1567
|
-
if (
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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";
|
|
1572
1591
|
}
|
|
1573
|
-
return false;
|
|
1574
1592
|
}
|
|
1575
|
-
function
|
|
1576
|
-
if (!
|
|
1577
|
-
let text;
|
|
1593
|
+
function readCollectorPid() {
|
|
1594
|
+
if (!existsSync2(COLLECTOR_PID_PATH)) return null;
|
|
1578
1595
|
try {
|
|
1579
|
-
|
|
1596
|
+
const pid = parseInt(readFileSync2(COLLECTOR_PID_PATH, "utf8").trim(), 10);
|
|
1597
|
+
return Number.isInteger(pid) ? pid : null;
|
|
1580
1598
|
} catch {
|
|
1581
|
-
return
|
|
1599
|
+
return null;
|
|
1582
1600
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
} catch {
|
|
1590
|
-
}
|
|
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 };
|
|
1591
1607
|
}
|
|
1592
|
-
|
|
1608
|
+
if (!existsSync2(COLLECTOR_BIN_PATH)) {
|
|
1609
|
+
throw new Error(
|
|
1610
|
+
"Collector binary is missing. Run `npx @marginfront/code-cost-clarity init` first."
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
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 };
|
|
1593
1623
|
}
|
|
1594
|
-
function
|
|
1595
|
-
|
|
1596
|
-
|
|
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;
|
|
1633
|
+
}
|
|
1597
1634
|
try {
|
|
1598
|
-
|
|
1599
|
-
|
|
1635
|
+
process2.kill(pid);
|
|
1636
|
+
log(`Stopped collector (pid ${pid}).`);
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
log(`Could not stop collector (pid ${pid}): ${err.message}`);
|
|
1600
1639
|
}
|
|
1640
|
+
rmSync2(COLLECTOR_PID_PATH, { force: true });
|
|
1641
|
+
return true;
|
|
1601
1642
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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]]);
|
|
1614
1666
|
}
|
|
1615
1667
|
}
|
|
1616
|
-
function
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
const cursorPath = options.cursorPath;
|
|
1620
|
-
const pidFilePath = options.pidFilePath;
|
|
1621
|
-
if (!process2.env.MARGINFRONT_API_KEY) {
|
|
1622
|
-
console.error(
|
|
1623
|
-
"MARGINFRONT_API_KEY is not set. Set it in your shell before watching: export MARGINFRONT_API_KEY=..."
|
|
1624
|
-
);
|
|
1625
|
-
process2.exit(1);
|
|
1668
|
+
function unsetDesktopGuiEnv(runLaunchctl = defaultLaunchctlRunner) {
|
|
1669
|
+
for (const key of CCC_OWNED_TELEMETRY_KEYS) {
|
|
1670
|
+
runLaunchctl(["unsetenv", key]);
|
|
1626
1671
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1672
|
+
}
|
|
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."
|
|
1728
|
+
);
|
|
1632
1729
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
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).`
|
|
1635
1734
|
);
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
const
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
const row = successRowsFrom(result.parsed).find(
|
|
1656
|
-
(r) => r?.customerExternalId === rec.customerExternalId
|
|
1657
|
-
);
|
|
1658
|
-
const cost = row?.totalCostUsd != null ? `$${row.totalCostUsd}` : "$?";
|
|
1659
|
-
const eventId = row?.eventId ? ` event=${row.eventId}` : "";
|
|
1660
|
-
const reference = "source" in rec.metadata ? `src=${rec.metadata.source}` : `cc=$${rec.metadata.claudeCodeCostUsd ?? "?"}`;
|
|
1661
|
-
const volume = rec.quantity !== void 0 ? `qty=${rec.quantity} tool=${"toolName" in rec.metadata ? rec.metadata.toolName : "?"}` : `in=${rec.inputTokens} out=${rec.outputTokens}`;
|
|
1662
|
-
console.log(
|
|
1663
|
-
`[${stamp}] recorded ${rec.customerExternalId} \xB7 ${volume} \xB7 server=${cost} \xB7 ${reference}${eventId}`
|
|
1664
|
-
);
|
|
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."
|
|
1748
|
+
);
|
|
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);
|
|
1665
1754
|
}
|
|
1666
1755
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
);
|
|
1673
|
-
} else if (result.reason === "network") {
|
|
1674
|
-
console.error(
|
|
1675
|
-
`[${stamp}] could not reach MarginFront: ${result.message}`
|
|
1676
|
-
);
|
|
1677
|
-
} else {
|
|
1678
|
-
console.error(`[${stamp}] send failed (${result.reason}).`);
|
|
1679
|
-
}
|
|
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]);
|
|
1680
1761
|
}
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
`[${stamp}] send failed - queued ${toQueue.length} record(s) for retry (${queuedCount} now in queue).`
|
|
1690
|
-
);
|
|
1691
|
-
}
|
|
1692
|
-
if (shed > 0) {
|
|
1693
|
-
console.error(
|
|
1694
|
-
`[${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.`
|
|
1695
|
-
);
|
|
1696
|
-
}
|
|
1697
|
-
nextQueueRetryAt = Date.now() + queueBackoffMs;
|
|
1698
|
-
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
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.`
|
|
1769
|
+
);
|
|
1699
1770
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
const
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
writeQueue(queuePath, remaining);
|
|
1712
|
-
queuedCount = remaining.length;
|
|
1713
|
-
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
1714
|
-
nextQueueRetryAt = 0;
|
|
1715
|
-
console.log(
|
|
1716
|
-
`[${stamp}] queue: ${batch.length} retried record(s) landed \xB7 ${remaining.length} still queued.`
|
|
1717
|
-
);
|
|
1718
|
-
} else if (isRetryablePostResult(result)) {
|
|
1719
|
-
logFailure(result, stamp);
|
|
1720
|
-
const waitMs = queueBackoffMs;
|
|
1721
|
-
nextQueueRetryAt = Date.now() + waitMs;
|
|
1722
|
-
queueBackoffMs = Math.min(queueBackoffMs * 2, QUEUE_BACKOFF_MAX_MS);
|
|
1723
|
-
console.error(
|
|
1724
|
-
`[${stamp}] queue: retry failed, ${queued.length} record(s) still queued (next attempt in ~${Math.round(waitMs / 1e3)}s).`
|
|
1725
|
-
);
|
|
1726
|
-
} else {
|
|
1727
|
-
const remaining = queued.slice(batch.length);
|
|
1728
|
-
writeQueue(queuePath, remaining);
|
|
1729
|
-
queuedCount = remaining.length;
|
|
1730
|
-
console.error(
|
|
1731
|
-
`[${stamp}] queue: dropping ${batch.length} record(s) - terminal failure (server rejected them, retrying can't help).`
|
|
1732
|
-
);
|
|
1771
|
+
log("The background meter is loaded and will start at login from now on.");
|
|
1772
|
+
return { reloaded, plistPath, daemonCliPath };
|
|
1773
|
+
}
|
|
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);
|
|
1733
1782
|
}
|
|
1734
1783
|
}
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
for (const rawLine of lines) {
|
|
1742
|
-
const line = rawLine.trim();
|
|
1743
|
-
if (!line) continue;
|
|
1744
|
-
let parsed;
|
|
1745
|
-
try {
|
|
1746
|
-
parsed = JSON.parse(line);
|
|
1747
|
-
} catch {
|
|
1748
|
-
console.error("(skipped one telemetry line that wasn't valid JSON)");
|
|
1749
|
-
continue;
|
|
1750
|
-
}
|
|
1751
|
-
const body = buildRequestBodyForLine(parsed, {
|
|
1752
|
-
foldCache,
|
|
1753
|
-
fallbackCustomerId,
|
|
1754
|
-
rawSourceLine: line
|
|
1755
|
-
});
|
|
1756
|
-
if (!body.records.length) continue;
|
|
1757
|
-
const result = await postToMarginFront(body);
|
|
1758
|
-
const stamp = hhmmss();
|
|
1759
|
-
if (!result.ok) {
|
|
1760
|
-
logFailure(result, stamp);
|
|
1761
|
-
if (queuePath && isRetryablePostResult(result)) {
|
|
1762
|
-
enqueueFailed(body.records, stamp);
|
|
1763
|
-
} else if (queuePath) {
|
|
1764
|
-
console.error(
|
|
1765
|
-
`[${stamp}] dropping ${body.records.length} record(s): terminal send failure (not retryable).`
|
|
1766
|
-
);
|
|
1767
|
-
}
|
|
1768
|
-
continue;
|
|
1769
|
-
}
|
|
1770
|
-
logSuccess(body, result, stamp);
|
|
1771
|
-
if (queuedCount > 0) {
|
|
1772
|
-
queueBackoffMs = QUEUE_BACKOFF_START_MS;
|
|
1773
|
-
nextQueueRetryAt = 0;
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
if (nextCursor !== cursorBytes) {
|
|
1777
|
-
cursorBytes = nextCursor;
|
|
1778
|
-
writeWatchCursor(cursorPath, cursorBytes);
|
|
1779
|
-
}
|
|
1780
|
-
} finally {
|
|
1781
|
-
running = false;
|
|
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]);
|
|
1782
1790
|
}
|
|
1783
1791
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1792
|
+
return [...labels];
|
|
1793
|
+
}
|
|
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);
|
|
1804
|
+
}
|
|
1805
|
+
for (const label of toBootOut) {
|
|
1806
|
+
runLaunchctl(["bootout", `gui/${uid}/${label}`]);
|
|
1807
|
+
}
|
|
1808
|
+
if (existsSync2(plistPath)) {
|
|
1809
|
+
rmSync2(plistPath, { force: true });
|
|
1810
|
+
log(`Removed the background-job definition at ${plistPath}.`);
|
|
1811
|
+
}
|
|
1812
|
+
if (existsSync2(daemonCliPath)) {
|
|
1813
|
+
rmSync2(daemonCliPath, { force: true });
|
|
1814
|
+
log(`Removed the meter program copy at ${daemonCliPath}.`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
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;
|
|
1822
|
+
}
|
|
1823
|
+
function parseDaemonPrint(result) {
|
|
1824
|
+
if (result.status !== 0) {
|
|
1825
|
+
return { loaded: false, running: false, pid: null };
|
|
1826
|
+
}
|
|
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 };
|
|
1832
|
+
}
|
|
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 [];
|
|
1840
|
+
let text;
|
|
1841
|
+
try {
|
|
1842
|
+
text = readFileSync2(path, "utf8");
|
|
1843
|
+
} catch {
|
|
1844
|
+
return [];
|
|
1845
|
+
}
|
|
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");
|
|
1856
|
+
}
|
|
1857
|
+
function findCccZshrcSourceLines(zshrcPath = ZSHRC_PATH) {
|
|
1858
|
+
if (!existsSync2(zshrcPath)) return { found: false, matchedLines: [] };
|
|
1859
|
+
let text;
|
|
1860
|
+
try {
|
|
1861
|
+
text = readFileSync2(zshrcPath, "utf8");
|
|
1862
|
+
} catch {
|
|
1863
|
+
return { found: false, matchedLines: [] };
|
|
1864
|
+
}
|
|
1865
|
+
const matchedLines = text.split("\n").filter(lineSourcesCccEnv).map((line) => line.trim());
|
|
1866
|
+
return { found: matchedLines.length > 0, matchedLines };
|
|
1867
|
+
}
|
|
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;
|
|
1877
|
+
try {
|
|
1878
|
+
text = readFileSync2(zshrcPath, "utf8");
|
|
1879
|
+
} catch {
|
|
1880
|
+
log(
|
|
1881
|
+
`Could not read ${zshrcPath}, so I left it untouched - remove the \`source ~/.marginfront-ccc/.env\` line by hand if you like.`
|
|
1795
1882
|
);
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1883
|
+
return { removed: [], backupPath: null };
|
|
1884
|
+
}
|
|
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);
|
|
1890
|
+
}
|
|
1891
|
+
if (removed.length === 0) {
|
|
1892
|
+
log(`Nothing to remove in ${zshrcPath}: no MarginFront source line found.`);
|
|
1893
|
+
return { removed: [], backupPath: null };
|
|
1894
|
+
}
|
|
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}).`
|
|
1900
|
+
);
|
|
1901
|
+
return { removed, backupPath };
|
|
1806
1902
|
}
|
|
1807
1903
|
|
|
1808
1904
|
// src/cli.ts
|
|
@@ -1820,8 +1916,10 @@ function printHelp() {
|
|
|
1820
1916
|
"",
|
|
1821
1917
|
"Commands:",
|
|
1822
1918
|
" init Set up everything: save your MarginFront secret key (mf_sk_...), not",
|
|
1823
|
-
" your Anthropic or OpenAI key.
|
|
1824
|
-
"
|
|
1919
|
+
" your Anthropic or OpenAI key. Ask which developer THIS machine's AI",
|
|
1920
|
+
" cost belongs to (for teams on a shared Claude/Codex login). Wire",
|
|
1921
|
+
" telemetry into your Claude/Codex config (with your OK), and start",
|
|
1922
|
+
" the background meter.",
|
|
1825
1923
|
" start (Re)start the background meter - starts at login, no terminal to keep open.",
|
|
1826
1924
|
" status Is the background meter running? Shows recent activity + errors.",
|
|
1827
1925
|
" preview <file.json> Show the exact record it would send for a capture. No key needed.",
|
|
@@ -1846,19 +1944,29 @@ function printHelp() {
|
|
|
1846
1944
|
" automatically. No `source`, no second terminal. Check on it with `status` anytime.",
|
|
1847
1945
|
"",
|
|
1848
1946
|
"Prefer a live terminal view instead of the background meter? After init, run:",
|
|
1849
|
-
" npx @marginfront/code-cost-clarity run"
|
|
1947
|
+
" npx @marginfront/code-cost-clarity run",
|
|
1948
|
+
"",
|
|
1949
|
+
"Sharing one Claude/Codex login across a team? (per-developer cost attribution)",
|
|
1950
|
+
" During `init` we ask which developer THIS machine's AI cost belongs to and save it",
|
|
1951
|
+
" as CCC_DEVELOPER_EMAIL in ~/.marginfront-ccc/.env. At send time the order is:",
|
|
1952
|
+
" 1. CCC_DEVELOPER_EMAIL (this machine's developer) - if set, it always wins;",
|
|
1953
|
+
" 2. otherwise the email the coding-agent login reports;",
|
|
1954
|
+
" 3. otherwise a no-identity placeholder.",
|
|
1955
|
+
" Leave it BLANK to keep the old auto-detect behavior. Edit the .env to change it,",
|
|
1956
|
+
" then `stop` and `start` (or just re-run `init`). Internal cost visibility only -",
|
|
1957
|
+
" it does NOT change anyone's bill."
|
|
1850
1958
|
].join("\n")
|
|
1851
1959
|
);
|
|
1852
1960
|
}
|
|
1853
1961
|
function printNoIdentityHint() {
|
|
1854
1962
|
console.log(
|
|
1855
1963
|
[
|
|
1856
|
-
"Note: usage with no
|
|
1964
|
+
"Note: usage with no developer email is attributed to a no-identity placeholder",
|
|
1857
1965
|
` ("${NO_IDENTITY_CUSTOMER}" for Claude Code, "${CODEX_NO_IDENTITY_CUSTOMER}" for Codex).`,
|
|
1858
1966
|
" Why: org-managed seats stamp the email automatically; some interactive logins don't.",
|
|
1859
1967
|
" Fix: sign in with an org-managed seat, or attach your own MarginFront customer",
|
|
1860
1968
|
" mapping. Until then the spend still lands, under the placeholder instead of the",
|
|
1861
|
-
"
|
|
1969
|
+
" developer's name."
|
|
1862
1970
|
].join("\n")
|
|
1863
1971
|
);
|
|
1864
1972
|
}
|
|
@@ -1912,6 +2020,30 @@ function promptYesNo(promptText, io = {}, defaultYes = false) {
|
|
|
1912
2020
|
rl.on("close", () => finish("", false));
|
|
1913
2021
|
});
|
|
1914
2022
|
}
|
|
2023
|
+
function promptVisible(promptText, defaultValue = "", io = {}) {
|
|
2024
|
+
return new Promise((resolve) => {
|
|
2025
|
+
const input = io.input ?? process3.stdin;
|
|
2026
|
+
const output = io.output ?? process3.stdout;
|
|
2027
|
+
const interactive = io.terminal ?? Boolean(input.isTTY);
|
|
2028
|
+
const rl = readline.createInterface({
|
|
2029
|
+
input,
|
|
2030
|
+
output,
|
|
2031
|
+
terminal: interactive
|
|
2032
|
+
});
|
|
2033
|
+
let settled = false;
|
|
2034
|
+
const finish = (answer) => {
|
|
2035
|
+
if (settled) return;
|
|
2036
|
+
settled = true;
|
|
2037
|
+
rl.close();
|
|
2038
|
+
resolve(answer.trim());
|
|
2039
|
+
};
|
|
2040
|
+
output.write(promptText);
|
|
2041
|
+
rl.question("", (answer) => finish(answer));
|
|
2042
|
+
if (interactive && defaultValue) rl.write(defaultValue);
|
|
2043
|
+
rl.on("SIGINT", () => finish(""));
|
|
2044
|
+
rl.on("close", () => finish(""));
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
1915
2047
|
function printConsentDisclosure(log) {
|
|
1916
2048
|
log(
|
|
1917
2049
|
[
|
|
@@ -1987,6 +2119,41 @@ async function cmdInit(noPrompt, deps = {}) {
|
|
|
1987
2119
|
keySaved = true;
|
|
1988
2120
|
}
|
|
1989
2121
|
}
|
|
2122
|
+
const shouldPromptDeveloper = !noPrompt && isTTY();
|
|
2123
|
+
if (shouldPromptDeveloper) {
|
|
2124
|
+
const gitDefault = (deps.gitDefaultEmail ?? (() => gitGlobalUserEmail()))();
|
|
2125
|
+
const savedDeveloperEmail = loadEnv()["CCC_DEVELOPER_EMAIL"];
|
|
2126
|
+
const defaultEmail = savedDeveloperEmail || gitDefault;
|
|
2127
|
+
log(
|
|
2128
|
+
[
|
|
2129
|
+
"",
|
|
2130
|
+
"Who should this machine's AI cost be attributed to?",
|
|
2131
|
+
" If your team shares ONE Claude/Codex login, every developer's usage looks",
|
|
2132
|
+
" like the same person. Naming the developer here splits this laptop's spend",
|
|
2133
|
+
" back out to them. (Internal cost visibility only - it does NOT change anyone's bill.)",
|
|
2134
|
+
"",
|
|
2135
|
+
" - Press Enter to accept the pre-filled email (from your global git config).",
|
|
2136
|
+
" - Type a different email to attribute this machine to someone else.",
|
|
2137
|
+
" - Clear it and leave it BLANK to auto-detect from your coding-agent login",
|
|
2138
|
+
" (today's behavior - usage is tagged with whatever email the login reports).",
|
|
2139
|
+
""
|
|
2140
|
+
].join("\n")
|
|
2141
|
+
);
|
|
2142
|
+
const askDeveloper = deps.promptDeveloperEmail ?? ((defaultValue) => promptVisible(
|
|
2143
|
+
"Developer email for this machine's AI cost: ",
|
|
2144
|
+
defaultValue
|
|
2145
|
+
));
|
|
2146
|
+
const chosenEmail = (await askDeveloper(defaultEmail)).trim();
|
|
2147
|
+
const writeDeveloper = deps.writeDeveloperEmail ?? ((value) => writeDeveloperEmailToEnv(value));
|
|
2148
|
+
writeDeveloper(chosenEmail);
|
|
2149
|
+
if (chosenEmail) {
|
|
2150
|
+
log(`Will attribute this machine's AI cost to ${chosenEmail}.`);
|
|
2151
|
+
} else {
|
|
2152
|
+
log(
|
|
2153
|
+
"Left attribution on auto-detect (this machine's AI cost follows the coding-agent login)."
|
|
2154
|
+
);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
1990
2157
|
printConsentDisclosure(log);
|
|
1991
2158
|
let consented;
|
|
1992
2159
|
if (noPrompt) {
|
|
@@ -2148,8 +2315,10 @@ async function cmdStart(deps = {}) {
|
|
|
2148
2315
|
errorLog(
|
|
2149
2316
|
[
|
|
2150
2317
|
"",
|
|
2151
|
-
|
|
2152
|
-
|
|
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)`
|
|
2153
2322
|
].join("\n")
|
|
2154
2323
|
);
|
|
2155
2324
|
return 1;
|
|
@@ -2179,6 +2348,10 @@ function printDaemonLogTail(label, path) {
|
|
|
2179
2348
|
for (const line of lines) console.log(` ${line}`);
|
|
2180
2349
|
}
|
|
2181
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
|
+
);
|
|
2182
2355
|
const status = queryDaemonStatus();
|
|
2183
2356
|
if (!status.loaded) {
|
|
2184
2357
|
console.log(
|
|
@@ -2209,9 +2382,12 @@ function cmdPreview(positionals, foldCache) {
|
|
|
2209
2382
|
return 1;
|
|
2210
2383
|
}
|
|
2211
2384
|
const parsedOtlp = readAndParseOtlpFile(inputFilePath);
|
|
2385
|
+
loadEnvFile();
|
|
2386
|
+
const developerOverride = resolveDeveloperOverride(process3.env);
|
|
2212
2387
|
const body = buildRequestBodyForLine(parsedOtlp, {
|
|
2213
2388
|
foldCache,
|
|
2214
|
-
fallbackCustomerId: NO_IDENTITY_CUSTOMER
|
|
2389
|
+
fallbackCustomerId: NO_IDENTITY_CUSTOMER,
|
|
2390
|
+
developerOverride
|
|
2215
2391
|
});
|
|
2216
2392
|
console.log(JSON.stringify(body, null, 2));
|
|
2217
2393
|
const usedFallback = body.records.some(
|
|
@@ -2362,12 +2538,31 @@ async function cmdStop() {
|
|
|
2362
2538
|
}
|
|
2363
2539
|
return 0;
|
|
2364
2540
|
}
|
|
2365
|
-
function cmdUninstall(purge, deps = {}) {
|
|
2541
|
+
async function cmdUninstall(purge, deps = {}) {
|
|
2366
2542
|
const log = deps.log ?? console.log;
|
|
2367
2543
|
const removeDaemon = deps.uninstallDaemon ?? (() => uninstallDaemon({ log }));
|
|
2368
2544
|
const stopColl = deps.stopCollector ?? (() => {
|
|
2369
2545
|
stopCollector({ log });
|
|
2370
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());
|
|
2371
2566
|
const removeClaude = deps.removeClaudeTelemetry ?? (() => removeClaudeTelemetrySettings({ log }));
|
|
2372
2567
|
const removeCodex = deps.removeCodexConfig ?? (() => removeCodexOtelConfig({ log }));
|
|
2373
2568
|
const removeFile = deps.removeRuntimeFile ?? ((path) => {
|
|
@@ -2380,7 +2575,23 @@ function cmdUninstall(purge, deps = {}) {
|
|
|
2380
2575
|
const findZshrc = deps.findZshrcLines ?? (() => findCccZshrcSourceLines());
|
|
2381
2576
|
const unsetDesktop = deps.unsetDesktopEnv ?? (() => unsetDesktopGuiEnv());
|
|
2382
2577
|
removeDaemon();
|
|
2383
|
-
|
|
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
|
+
);
|
|
2384
2595
|
removeClaude();
|
|
2385
2596
|
removeCodex();
|
|
2386
2597
|
unsetDesktop();
|
|
@@ -2461,7 +2672,7 @@ async function main() {
|
|
|
2461
2672
|
case "stop":
|
|
2462
2673
|
return cmdStop();
|
|
2463
2674
|
case "uninstall":
|
|
2464
|
-
return cmdUninstall(flags.has("--purge"));
|
|
2675
|
+
return await cmdUninstall(flags.has("--purge"));
|
|
2465
2676
|
default:
|
|
2466
2677
|
console.error(`Unknown command "${command}".
|
|
2467
2678
|
`);
|
|
@@ -2490,6 +2701,7 @@ export {
|
|
|
2490
2701
|
cmdRun,
|
|
2491
2702
|
cmdStart,
|
|
2492
2703
|
cmdUninstall,
|
|
2704
|
+
promptVisible,
|
|
2493
2705
|
promptYesNo,
|
|
2494
2706
|
runningForwarderPid,
|
|
2495
2707
|
stopForwarderThenStopCollector
|