@nurix/nustack 0.20.0 → 0.20.2
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.
|
@@ -4,9 +4,9 @@ import { printJsonSuccess } from "../lib/json_output.js";
|
|
|
4
4
|
import { openLocalStore } from "../lib/local_store/database.js";
|
|
5
5
|
import { getBindingByRepositoryRoot, listActiveBindings, setBindingTelemetry, } from "../lib/local_store/bindings.js";
|
|
6
6
|
import { upsertFilebeatState } from "../lib/local_store/filebeat_state.js";
|
|
7
|
-
import { ensureFilebeatBinary
|
|
7
|
+
import { ensureFilebeatBinary } from "../lib/filebeat_installer.js";
|
|
8
8
|
import { collectTelemetryStatus } from "../lib/telemetry/health_report.js";
|
|
9
|
-
import { ensureBindingTelemetry } from "../lib/session_telemetry.js";
|
|
9
|
+
import { ensureBindingTelemetry, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
|
|
10
10
|
import { resolveRepository } from "../lib/git-sync/core/repository.js";
|
|
11
11
|
function resolveTargetBinding(store, options) {
|
|
12
12
|
if (options.device)
|
|
@@ -50,6 +50,38 @@ export async function runTelemetry(action, options) {
|
|
|
50
50
|
p.outro("Telemetry status.");
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
|
+
if (action === "output") {
|
|
54
|
+
const elkUrl = options.elasticUrl ?? process.env.NUSTACK_ELASTIC_URL;
|
|
55
|
+
const apiKey = process.env.NUSTACK_ELASTIC_API_KEY;
|
|
56
|
+
if (!elkUrl || !apiKey) {
|
|
57
|
+
throw new Error("telemetry output needs --elastic-url (or NUSTACK_ELASTIC_URL) and NUSTACK_ELASTIC_API_KEY in the environment");
|
|
58
|
+
}
|
|
59
|
+
const result = await setLocalOutput({
|
|
60
|
+
baseUrl: resolveNuStackUrl(options),
|
|
61
|
+
store,
|
|
62
|
+
elkUrl,
|
|
63
|
+
apiKey,
|
|
64
|
+
index: options.index,
|
|
65
|
+
});
|
|
66
|
+
const status = await collectTelemetryStatus(store, undefined, verbose);
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
if (json) {
|
|
70
|
+
printJsonSuccess("telemetry output", { scope: "device", bindingId: null, applied: false, errorCode: result.errorCode, ...status });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
p.log.error(`The shipper refused the output (${result.errorCode}); the previous config is still live.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (json) {
|
|
77
|
+
printJsonSuccess("telemetry output", { scope: "device", bindingId: null, applied: true, ...status });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
p.intro("nustack telemetry output");
|
|
81
|
+
p.log.success(`Shipping to ${new URL(elkUrl).host}; reconciled ${result.reconciled} folder${result.reconciled === 1 ? "" : "s"}; install state ${status.filebeatState}.`);
|
|
82
|
+
p.outro("A credential the server brokers later replaces this output.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
53
85
|
const target = action === "reinstall" ? null : resolveTargetBinding(store, options);
|
|
54
86
|
if (target) {
|
|
55
87
|
const enabled = action === "enable";
|
|
@@ -69,22 +101,24 @@ export async function runTelemetry(action, options) {
|
|
|
69
101
|
p.intro(`nustack telemetry ${action}`);
|
|
70
102
|
p.log.success(enabled
|
|
71
103
|
? `${folderLabel(target)} is shipping. Other folders are unchanged.`
|
|
72
|
-
: `${folderLabel(target)} has stopped shipping
|
|
104
|
+
: `${folderLabel(target)} has stopped shipping; nothing it records while off will ever ship. Other folders are unchanged.`);
|
|
73
105
|
p.outro(`Run \`nustack telemetry ${enabled ? "disable" : "enable"}\` here to reverse it.`);
|
|
74
106
|
return;
|
|
75
107
|
}
|
|
76
108
|
if (action === "disable") {
|
|
77
|
-
|
|
78
|
-
upsertFilebeatState(store, { telemetryEnabled: false, processState: "stopped" });
|
|
109
|
+
const result = await switchMachineOff({ store });
|
|
79
110
|
if (json) {
|
|
80
111
|
printJsonSuccess("telemetry disable", {
|
|
81
112
|
scope: "device",
|
|
82
113
|
bindingId: null,
|
|
83
114
|
telemetryEnabled: false,
|
|
115
|
+
shipper: result,
|
|
84
116
|
});
|
|
85
117
|
return;
|
|
86
118
|
}
|
|
87
|
-
p.log.success(
|
|
119
|
+
p.log.success(result === "skipping"
|
|
120
|
+
? "Telemetry is off on this machine: the shipper keeps reading and discards everything, so nothing recorded while off will ever ship (Document sync untouched)."
|
|
121
|
+
: "Telemetry is off on this machine. The shipper refused the switch-off config, so it was stopped instead: sessions recorded while off will ship when you turn it back on.");
|
|
88
122
|
p.outro("Run `nustack telemetry enable` to resume.");
|
|
89
123
|
return;
|
|
90
124
|
}
|
package/dist/index.js
CHANGED
|
@@ -179,18 +179,22 @@ device
|
|
|
179
179
|
.action(async (deviceId, options) => runDeviceRevoke(deviceId, options, program.opts()));
|
|
180
180
|
program
|
|
181
181
|
.command("telemetry [action]")
|
|
182
|
-
.description("Inspect or control telemetry (status | enable | disable | reinstall). Inside a bound folder, enable/disable move that folder alone")
|
|
182
|
+
.description("Inspect or control telemetry (status | enable | disable | reinstall | output). Inside a bound folder, enable/disable move that folder alone; output points the shipper at your own Elastic, its key read from NUSTACK_ELASTIC_API_KEY")
|
|
183
183
|
.option("--json", "emit the versioned machine envelope")
|
|
184
184
|
.option("--device", "move this machine's switch instead of the current folder's")
|
|
185
185
|
.option("--local-verbose", "include each folder's absolute path (local consumers only — never pipe into a log)")
|
|
186
|
+
.option("--elastic-url <url>", "output: the Elastic endpoint (default NUSTACK_ELASTIC_URL)")
|
|
187
|
+
.option("--index <name>", "output: the index events are written to (default claude-sessions)")
|
|
186
188
|
.action(async (action, options) => {
|
|
187
|
-
const allowed = ["status", "enable", "disable", "reinstall"];
|
|
189
|
+
const allowed = ["status", "enable", "disable", "reinstall", "output"];
|
|
188
190
|
const chosen = (allowed.includes(action ?? "") ? action : "status");
|
|
189
191
|
return runTelemetry(chosen, {
|
|
190
192
|
...program.opts(),
|
|
191
193
|
json: options.json,
|
|
192
194
|
device: options.device,
|
|
193
195
|
localVerbose: options.localVerbose,
|
|
196
|
+
elasticUrl: options.elasticUrl,
|
|
197
|
+
index: options.index,
|
|
194
198
|
});
|
|
195
199
|
});
|
|
196
200
|
const toLimit = (value) => Number.parseInt(value, 10);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ensureAccessToken } from "./auth_grant.js";
|
|
6
6
|
import { fetchBrokeredCredential } from "./nustack_client.js";
|
|
@@ -9,8 +9,8 @@ import { openLocalStore } from "./local_store/database.js";
|
|
|
9
9
|
import { readDeviceState } from "./local_store/device_state.js";
|
|
10
10
|
import { listActiveBindings } from "./local_store/bindings.js";
|
|
11
11
|
import { listActivityBindings, readFilebeatState, upsertActivityBinding, upsertFilebeatState, } from "./local_store/filebeat_state.js";
|
|
12
|
-
import { ensureFilebeatBinary, filebeatBinPath, filebeatDir, desiredFilebeatVersion, observedFilebeatVersion, probeServiceRegistered, registerAndStartService, restartService, testFilebeatConfig, } from "./filebeat_installer.js";
|
|
13
|
-
import {
|
|
12
|
+
import { ensureFilebeatBinary, filebeatBinPath, filebeatDir, mainConfigPath, desiredFilebeatVersion, observedFilebeatVersion, probeServiceRegistered, registerAndStartService, restartService, testFilebeatConfig, unregisterService, } from "./filebeat_installer.js";
|
|
13
|
+
import { INPUTS_FILE, LOCAL_OUTPUT_HEADER, renderCentralConfig, renderInputsFile, } from "./telemetry/config_renderer.js";
|
|
14
14
|
import { applyConfigChange, removeInputFile } from "./telemetry/reload.js";
|
|
15
15
|
import { discoverProviderBindings } from "./telemetry/provider_paths.js";
|
|
16
16
|
import { reportTelemetryHealth } from "./telemetry/health_report.js";
|
|
@@ -28,6 +28,7 @@ function defaultSeams() {
|
|
|
28
28
|
registerService: () => registerAndStartService(),
|
|
29
29
|
serviceRegistered: () => probeServiceRegistered(),
|
|
30
30
|
restartService: () => restartService(),
|
|
31
|
+
unregisterService: () => unregisterService(),
|
|
31
32
|
fetchImpl: fetch,
|
|
32
33
|
ensureAccessToken: (baseUrl) => ensureAccessToken(baseUrl),
|
|
33
34
|
};
|
|
@@ -109,62 +110,64 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
109
110
|
const device = readDeviceState(store);
|
|
110
111
|
const deviceRef = device?.deviceRef ?? "";
|
|
111
112
|
const userId = device?.activeUserId ?? "";
|
|
113
|
+
const localOutput = await hasLocalOutput();
|
|
112
114
|
const auth = await seams.ensureAccessToken(baseUrl);
|
|
113
|
-
if (!auth) {
|
|
115
|
+
if (!auth && !localOutput) {
|
|
114
116
|
recordDegraded(store, "no_device_token");
|
|
115
117
|
await reportBestEffort(baseUrl, store, seams, null);
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
120
|
+
const accessToken = auth?.accessToken ?? null;
|
|
118
121
|
if (!binding.telemetryEnabled) {
|
|
119
122
|
await removeBindingTelemetry({ store, bindingId: binding.id, seams: opts.seams });
|
|
120
|
-
await reportBestEffort(baseUrl, store, seams,
|
|
123
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
121
124
|
return;
|
|
122
125
|
}
|
|
123
126
|
let brokerFailure = null;
|
|
124
|
-
const credential =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
const credential = accessToken
|
|
128
|
+
? await fetchBrokeredCredential(baseUrl, accessToken, {
|
|
129
|
+
projectId: binding.projectId,
|
|
130
|
+
service: TELEMETRY_SERVICE,
|
|
131
|
+
}).catch((error) => {
|
|
132
|
+
brokerFailure = error instanceof Error ? error.message : String(error);
|
|
133
|
+
return null;
|
|
134
|
+
})
|
|
135
|
+
: null;
|
|
136
|
+
if (!credential?.url && !localOutput) {
|
|
132
137
|
const unserved = brokerFailure !== null && brokerFailure.includes("does not serve");
|
|
133
138
|
recordDegraded(store, unserved ? "credential_plane_unserved" : "no_output_credential");
|
|
134
|
-
await reportBestEffort(baseUrl, store, seams,
|
|
139
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
135
140
|
return;
|
|
136
141
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
142
|
+
if (credential?.url) {
|
|
143
|
+
const index = credential.meta?.index || DEFAULT_INDEX;
|
|
144
|
+
const central = {
|
|
145
|
+
relativePath: "filebeat.yml",
|
|
146
|
+
contents: renderCentralConfig({ elkUrl: credential.url, apiKey: credential.key, index }),
|
|
147
|
+
};
|
|
148
|
+
const centralApply = await applyConfigChange({
|
|
149
|
+
files: [central],
|
|
150
|
+
scope: "central",
|
|
151
|
+
context: centralReloadContext(seams, filebeatDir()),
|
|
152
|
+
});
|
|
153
|
+
if (!centralApply.ok) {
|
|
154
|
+
recordDegraded(store, centralApply.errorCode);
|
|
155
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
151
158
|
}
|
|
152
159
|
if (!seams.serviceRegistered())
|
|
153
160
|
await seams.registerService();
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
{ relativePath: `inputs.d/${binding.id}.yml`, contents: renderBindingInput(toTelemetryBinding(binding, deviceRef, userId)) },
|
|
157
|
-
{ relativePath: "inputs.d/codex-shared.yml", contents: renderCodexSharedInput(bindings) },
|
|
158
|
-
];
|
|
159
|
-
await removeOrphanInputs(bindings);
|
|
161
|
+
const inputs = renderInputs(store, { id: binding.id, binding, enabled: true }, deviceRef, userId) ?? "";
|
|
162
|
+
await removeOrphanInputs();
|
|
160
163
|
const inputsApply = await applyConfigChange({
|
|
161
|
-
files: inputs,
|
|
164
|
+
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
162
165
|
scope: "inputs",
|
|
163
166
|
context: seams.reloadContext(filebeatDir()),
|
|
164
167
|
});
|
|
165
168
|
if (!inputsApply.ok) {
|
|
166
169
|
recordDegraded(store, inputsApply.errorCode);
|
|
167
|
-
await reportBestEffort(baseUrl, store, seams,
|
|
170
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
168
171
|
return;
|
|
169
172
|
}
|
|
170
173
|
for (const discovered of discoverProviderBindings(binding.repositoryRoot)) {
|
|
@@ -185,31 +188,83 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
185
188
|
processState: "running",
|
|
186
189
|
lastErrorCode: null,
|
|
187
190
|
});
|
|
188
|
-
await reportBestEffort(baseUrl, store, seams,
|
|
191
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
189
192
|
}
|
|
190
193
|
catch (error) {
|
|
191
194
|
recordDegraded(store, "binding_reconcile_failed");
|
|
192
195
|
void message(error);
|
|
193
196
|
}
|
|
194
197
|
}
|
|
198
|
+
export async function hasLocalOutput() {
|
|
199
|
+
try {
|
|
200
|
+
const contents = await readFile(mainConfigPath(), "utf8");
|
|
201
|
+
return contents.split("\n", 1)[0] === LOCAL_OUTPUT_HEADER;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function setLocalOutput(opts) {
|
|
208
|
+
const seams = { ...defaultSeams(), ...opts.seams };
|
|
209
|
+
const { store, baseUrl } = opts;
|
|
210
|
+
await seams.ensureBinary(NO_OP_LOG);
|
|
211
|
+
const central = {
|
|
212
|
+
relativePath: "filebeat.yml",
|
|
213
|
+
contents: renderCentralConfig({
|
|
214
|
+
elkUrl: opts.elkUrl,
|
|
215
|
+
apiKey: opts.apiKey,
|
|
216
|
+
index: opts.index || DEFAULT_INDEX,
|
|
217
|
+
origin: "local",
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
const centralApply = await applyConfigChange({
|
|
221
|
+
files: [central],
|
|
222
|
+
scope: "central",
|
|
223
|
+
context: centralReloadContext(seams, filebeatDir()),
|
|
224
|
+
});
|
|
225
|
+
if (!centralApply.ok) {
|
|
226
|
+
recordDegraded(store, centralApply.errorCode);
|
|
227
|
+
return { ok: false, errorCode: centralApply.errorCode };
|
|
228
|
+
}
|
|
229
|
+
if (!seams.serviceRegistered())
|
|
230
|
+
await seams.registerService();
|
|
231
|
+
upsertFilebeatState(store, {
|
|
232
|
+
binaryPath: existsSync(filebeatBinPath()) ? filebeatBinPath() : null,
|
|
233
|
+
configDir: filebeatDir(),
|
|
234
|
+
desiredVersion: desiredFilebeatVersion(),
|
|
235
|
+
observedVersion: observedFilebeatVersion(),
|
|
236
|
+
processState: "running",
|
|
237
|
+
telemetryEnabled: true,
|
|
238
|
+
lastErrorCode: null,
|
|
239
|
+
});
|
|
240
|
+
const bindings = listActiveBindings(store);
|
|
241
|
+
for (const binding of bindings) {
|
|
242
|
+
await ensureBindingTelemetry({ baseUrl, store, binding, seams: opts.seams });
|
|
243
|
+
}
|
|
244
|
+
if (bindings.length === 0)
|
|
245
|
+
await reportBestEffort(baseUrl, store, seams);
|
|
246
|
+
return { ok: true, reconciled: bindings.length };
|
|
247
|
+
}
|
|
195
248
|
export async function removeBindingTelemetry(opts) {
|
|
196
249
|
const seams = { ...defaultSeams(), ...opts.seams };
|
|
197
250
|
const { store, bindingId } = opts;
|
|
198
251
|
try {
|
|
199
|
-
await removeInputFile(filebeatDir(), `inputs.d/${bindingId}.yml`);
|
|
200
252
|
const device = readDeviceState(store);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
await removeInputFile(filebeatDir(), "inputs.d/codex-shared.yml");
|
|
253
|
+
await removeOrphanInputs();
|
|
254
|
+
const inputs = renderInputs(store, { id: bindingId, enabled: false }, device?.deviceRef ?? "", device?.activeUserId ?? "");
|
|
255
|
+
if (inputs === null) {
|
|
256
|
+
await removeInputFile(filebeatDir(), INPUTS_FILE);
|
|
206
257
|
}
|
|
207
258
|
else {
|
|
208
|
-
await applyConfigChange({
|
|
209
|
-
files: [{ relativePath:
|
|
259
|
+
const applied = await applyConfigChange({
|
|
260
|
+
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
210
261
|
scope: "inputs",
|
|
211
262
|
context: seams.reloadContext(filebeatDir()),
|
|
212
263
|
});
|
|
264
|
+
if (!applied.ok) {
|
|
265
|
+
await removeInputFile(filebeatDir(), INPUTS_FILE);
|
|
266
|
+
recordDegraded(store, applied.errorCode);
|
|
267
|
+
}
|
|
213
268
|
}
|
|
214
269
|
for (const row of listActivityBindings(store, bindingId)) {
|
|
215
270
|
upsertActivityBinding(store, {
|
|
@@ -225,22 +280,53 @@ export async function removeBindingTelemetry(opts) {
|
|
|
225
280
|
catch {
|
|
226
281
|
}
|
|
227
282
|
}
|
|
228
|
-
function
|
|
283
|
+
export async function switchMachineOff(opts) {
|
|
284
|
+
const seams = { ...defaultSeams(), ...opts.seams };
|
|
285
|
+
const { store } = opts;
|
|
286
|
+
upsertFilebeatState(store, { telemetryEnabled: false });
|
|
287
|
+
const device = readDeviceState(store);
|
|
288
|
+
const deviceRef = device?.deviceRef ?? "";
|
|
289
|
+
const userId = device?.activeUserId ?? "";
|
|
290
|
+
const entries = listActiveBindings(store).map((b) => ({ binding: toTelemetryBinding(b, deviceRef, userId), enabled: false }));
|
|
291
|
+
try {
|
|
292
|
+
await removeOrphanInputs();
|
|
293
|
+
if (entries.length === 0) {
|
|
294
|
+
await removeInputFile(filebeatDir(), INPUTS_FILE);
|
|
295
|
+
return "skipping";
|
|
296
|
+
}
|
|
297
|
+
const applied = await applyConfigChange({
|
|
298
|
+
files: [{ relativePath: INPUTS_FILE, contents: renderInputsFile(entries, { deviceRef, userId }) }],
|
|
299
|
+
scope: "inputs",
|
|
300
|
+
context: seams.reloadContext(filebeatDir()),
|
|
301
|
+
});
|
|
302
|
+
if (applied.ok)
|
|
303
|
+
return "skipping";
|
|
304
|
+
recordDegraded(store, applied.errorCode);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
recordDegraded(store, "config_invalid");
|
|
308
|
+
}
|
|
309
|
+
seams.unregisterService();
|
|
310
|
+
upsertFilebeatState(store, { processState: "stopped" });
|
|
311
|
+
return "stopped";
|
|
312
|
+
}
|
|
313
|
+
function renderInputs(store, reconciled, deviceRef, userId) {
|
|
229
314
|
const byId = new Map();
|
|
230
315
|
for (const b of listActiveBindings(store)) {
|
|
231
|
-
|
|
232
|
-
byId.set(b.id, toTelemetryBinding(b, deviceRef, userId));
|
|
316
|
+
byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled });
|
|
233
317
|
}
|
|
234
|
-
|
|
235
|
-
|
|
318
|
+
const known = reconciled.binding ? toTelemetryBinding(reconciled.binding, deviceRef, userId) : byId.get(reconciled.id)?.binding;
|
|
319
|
+
if (known)
|
|
320
|
+
byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled });
|
|
321
|
+
return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId });
|
|
236
322
|
}
|
|
237
|
-
async function removeOrphanInputs(
|
|
323
|
+
async function removeOrphanInputs() {
|
|
238
324
|
const inputsDirPath = path.join(filebeatDir(), "inputs.d");
|
|
239
325
|
if (!existsSync(inputsDirPath))
|
|
240
326
|
return;
|
|
241
|
-
const keep =
|
|
327
|
+
const keep = path.basename(INPUTS_FILE);
|
|
242
328
|
for (const name of await readdir(inputsDirPath)) {
|
|
243
|
-
if (name.endsWith(".yml") &&
|
|
329
|
+
if (name.endsWith(".yml") && name !== keep) {
|
|
244
330
|
await removeInputFile(filebeatDir(), `inputs.d/${name}`);
|
|
245
331
|
}
|
|
246
332
|
}
|
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { assertSafeApiKey, assertSafeElkUrl, assertSafeIndexName } from "../guards.js";
|
|
3
3
|
import { claudeProjectDir, codexSessionsDir } from "./provider_paths.js";
|
|
4
4
|
import { REDACTION_POLICY_VERSION, SLASH_COMMAND_CLOSE, SLASH_COMMAND_OPEN, providerDropFields, } from "./redaction_policy.js";
|
|
5
|
-
export const TELEMETRY_CONFIG_VERSION =
|
|
5
|
+
export const TELEMETRY_CONFIG_VERSION = 3;
|
|
6
6
|
export const ENVELOPE_SCHEMA_VERSION = 2;
|
|
7
7
|
export function renderTemplate(template, vars) {
|
|
8
8
|
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, name) => {
|
|
@@ -32,7 +32,9 @@ export function claudeFilestreamId(repoRoot) {
|
|
|
32
32
|
export function desiredConfigHash(bytes) {
|
|
33
33
|
return createHash("sha256").update(bytes).digest("hex");
|
|
34
34
|
}
|
|
35
|
-
const
|
|
35
|
+
export const BROKERED_OUTPUT_HEADER = "# Managed by the nustack CLI — do not edit by hand.";
|
|
36
|
+
export const LOCAL_OUTPUT_HEADER = "# Managed by the nustack CLI — local output set by `nustack telemetry output`; do not edit by hand.";
|
|
37
|
+
const CENTRAL_TEMPLATE = `{{header}}
|
|
36
38
|
filebeat.config.inputs:
|
|
37
39
|
enabled: true
|
|
38
40
|
path: \${path.config}/inputs.d/*.yml
|
|
@@ -60,9 +62,9 @@ output.elasticsearch:
|
|
|
60
62
|
index: {{index}}
|
|
61
63
|
ssl.verification_mode: full
|
|
62
64
|
|
|
65
|
+
# The index and its template already exist, so the shipping key needs no template privilege.
|
|
63
66
|
setup.ilm.enabled: false
|
|
64
|
-
setup.template.
|
|
65
|
-
setup.template.pattern: {{index_pattern}}
|
|
67
|
+
setup.template.enabled: false
|
|
66
68
|
|
|
67
69
|
logging.level: warning
|
|
68
70
|
`;
|
|
@@ -71,13 +73,21 @@ export function renderCentralConfig(opts) {
|
|
|
71
73
|
const apiKey = assertSafeApiKey(opts.apiKey);
|
|
72
74
|
const index = assertSafeIndexName(opts.index);
|
|
73
75
|
return renderTemplate(CENTRAL_TEMPLATE, {
|
|
76
|
+
header: opts.origin === "local" ? LOCAL_OUTPUT_HEADER : BROKERED_OUTPUT_HEADER,
|
|
74
77
|
elk_url: yamlScalar(elkUrl),
|
|
75
78
|
api_key: yamlScalar(apiKey),
|
|
76
79
|
index: yamlScalar(index),
|
|
77
|
-
index_pattern: yamlScalar(`${index}-*`),
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
|
-
const
|
|
82
|
+
export const INPUTS_FILE = "inputs.d/inputs.yml";
|
|
83
|
+
export const CODEX_INPUT_KEY = "codex-shared";
|
|
84
|
+
const INPUTS_HEADER = `# Managed by the nustack CLI — one input per bound folder, then the shared Codex input.
|
|
85
|
+
# A switched-off folder's input still reads its sessions and drops every event, so nothing written
|
|
86
|
+
# while it is off ever ships — \`nustack telemetry enable | disable\` inside the folder flips it;
|
|
87
|
+
# do not edit by hand.
|
|
88
|
+
`;
|
|
89
|
+
const BLOCK_MARKER = /^# (?:binding: (\S+)|codex: shared)$/gm;
|
|
90
|
+
const BINDING_TEMPLATE = `# binding: {{binding_id_raw}}
|
|
81
91
|
- type: filestream
|
|
82
92
|
id: {{filestream_id}}
|
|
83
93
|
paths:
|
|
@@ -109,8 +119,9 @@ const BINDING_TEMPLATE = `# Managed by the nustack CLI — binding: {{binding_id
|
|
|
109
119
|
fields: [{{drop_fields}}]
|
|
110
120
|
ignore_missing: true
|
|
111
121
|
`;
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
const SWITCHED_OFF = " - drop_event: {} # switched off: read and discarded, never shipped\n";
|
|
123
|
+
export function renderBindingInput(binding, enabled = true) {
|
|
124
|
+
const block = renderTemplate(BINDING_TEMPLATE, {
|
|
114
125
|
binding_id_raw: binding.bindingId,
|
|
115
126
|
filestream_id: claudeFilestreamId(binding.repoRoot),
|
|
116
127
|
claude_glob: yamlScalar(`${claudeProjectDir(binding.repoRoot)}/**/*.jsonl`),
|
|
@@ -126,15 +137,15 @@ export function renderBindingInput(binding) {
|
|
|
126
137
|
slash_close: SLASH_COMMAND_CLOSE,
|
|
127
138
|
drop_fields: providerDropFields("claude_code").join(", "),
|
|
128
139
|
});
|
|
140
|
+
return enabled ? block : block.replace("\n processors:\n", `\n processors:\n${SWITCHED_OFF}`);
|
|
129
141
|
}
|
|
130
|
-
export function renderCodexSharedInput(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const sorted = [...bindings].sort((a, b) => (a.bindingId < b.bindingId ? -1 : a.bindingId > b.bindingId ? 1 : 0));
|
|
135
|
-
const device = sorted[0];
|
|
142
|
+
export function renderCodexSharedInput(entries, device) {
|
|
143
|
+
const ordered = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
144
|
+
const sorted = ordered.filter((entry) => entry.enabled).map((entry) => entry.binding);
|
|
145
|
+
const drops = sorted.length === 0 ? SWITCHED_OFF : "";
|
|
136
146
|
const conditionals = sorted
|
|
137
|
-
.map((binding) => `
|
|
147
|
+
.map((binding) => `
|
|
148
|
+
- if:
|
|
138
149
|
equals:
|
|
139
150
|
cwd: ${yamlScalar(binding.repoRoot)}
|
|
140
151
|
then:
|
|
@@ -144,9 +155,9 @@ export function renderCodexSharedInput(bindings) {
|
|
|
144
155
|
organization_id: ${yamlScalar(binding.organizationId)}
|
|
145
156
|
project_id: ${yamlScalar(binding.projectId)}
|
|
146
157
|
binding_id: ${yamlScalar(binding.bindingId)}`)
|
|
147
|
-
.join("
|
|
158
|
+
.join("");
|
|
148
159
|
const dropList = ["cwd", ...providerDropFields("codex")].join(", ");
|
|
149
|
-
return `#
|
|
160
|
+
return `# codex: shared
|
|
150
161
|
- type: filestream
|
|
151
162
|
id: codex-shared
|
|
152
163
|
paths:
|
|
@@ -158,7 +169,7 @@ export function renderCodexSharedInput(bindings) {
|
|
|
158
169
|
add_error_key: true
|
|
159
170
|
fields_under_root: true
|
|
160
171
|
processors:
|
|
161
|
-
- add_fields:
|
|
172
|
+
${drops} - add_fields:
|
|
162
173
|
target: nustack
|
|
163
174
|
fields:
|
|
164
175
|
schema_version: ${ENVELOPE_SCHEMA_VERSION}
|
|
@@ -166,10 +177,25 @@ export function renderCodexSharedInput(bindings) {
|
|
|
166
177
|
user_id: ${yamlScalar(device.userId)}
|
|
167
178
|
provider: ${yamlScalar("codex")}
|
|
168
179
|
redaction_policy_version: ${REDACTION_POLICY_VERSION}
|
|
169
|
-
redaction_applied: true
|
|
170
|
-
${conditionals}
|
|
180
|
+
redaction_applied: true${conditionals}
|
|
171
181
|
- drop_fields:
|
|
172
182
|
fields: [${dropList}]
|
|
173
183
|
ignore_missing: true
|
|
174
184
|
`;
|
|
175
185
|
}
|
|
186
|
+
export function renderInputsFile(entries, device) {
|
|
187
|
+
const sorted = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
188
|
+
const blocks = sorted.map((entry) => renderBindingInput(entry.binding, entry.enabled));
|
|
189
|
+
return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device);
|
|
190
|
+
}
|
|
191
|
+
export function splitInputBlocks(contents) {
|
|
192
|
+
const starts = [...contents.matchAll(BLOCK_MARKER)].map((match) => ({
|
|
193
|
+
key: match[1] ?? CODEX_INPUT_KEY,
|
|
194
|
+
index: match.index ?? 0,
|
|
195
|
+
}));
|
|
196
|
+
const blocks = new Map();
|
|
197
|
+
starts.forEach((start, position) => {
|
|
198
|
+
blocks.set(start.key, contents.slice(start.index, starts[position + 1]?.index ?? contents.length));
|
|
199
|
+
});
|
|
200
|
+
return blocks;
|
|
201
|
+
}
|
|
@@ -4,9 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { listActiveBindings } from "../local_store/bindings.js";
|
|
5
5
|
import { readDeviceState } from "../local_store/device_state.js";
|
|
6
6
|
import { listActivityBindings, readFilebeatState, upsertFilebeatState, } from "../local_store/filebeat_state.js";
|
|
7
|
-
import { desiredFilebeatVersion, filebeatBinPath, filebeatDir,
|
|
7
|
+
import { desiredFilebeatVersion, filebeatBinPath, filebeatDir, observedFilebeatVersion, probeServiceRegistered, } from "../filebeat_installer.js";
|
|
8
8
|
import { REDACTION_POLICY_VERSION, } from "./redaction_policy.js";
|
|
9
|
-
import { TELEMETRY_CONFIG_VERSION, desiredConfigHash, renderBindingInput, } from "./config_renderer.js";
|
|
9
|
+
import { INPUTS_FILE, TELEMETRY_CONFIG_VERSION, desiredConfigHash, renderBindingInput, splitInputBlocks, } from "./config_renderer.js";
|
|
10
10
|
import { discoverProviderBindings } from "./provider_paths.js";
|
|
11
11
|
import { readObservedConfigBytes } from "./reload.js";
|
|
12
12
|
export function defaultProbes() {
|
|
@@ -18,8 +18,11 @@ export function defaultProbes() {
|
|
|
18
18
|
serviceRegistered: () => probeServiceRegistered(),
|
|
19
19
|
observedConfigBytes: () => readObservedConfigBytes(filebeatDir()),
|
|
20
20
|
bindingInputHash: async (bindingId) => {
|
|
21
|
-
const file = path.join(
|
|
22
|
-
|
|
21
|
+
const file = path.join(filebeatDir(), INPUTS_FILE);
|
|
22
|
+
if (!existsSync(file))
|
|
23
|
+
return null;
|
|
24
|
+
const block = splitInputBlocks(await readFile(file, "utf8")).get(bindingId);
|
|
25
|
+
return block === undefined ? null : desiredConfigHash(block);
|
|
23
26
|
},
|
|
24
27
|
};
|
|
25
28
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { copyFile, readdir, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { mkdirSecure, writeFileSecure } from "../secure_file.js";
|
|
5
|
+
import { CODEX_INPUT_KEY, INPUTS_FILE, splitInputBlocks } from "./config_renderer.js";
|
|
5
6
|
const MAIN_CONFIG = "filebeat.yml";
|
|
6
7
|
const INPUTS_DIR = "inputs.d";
|
|
7
8
|
async function liveConfigFiles(configDir) {
|
|
@@ -75,10 +76,8 @@ export async function removeInputFile(configDir, relativePath) {
|
|
|
75
76
|
await rm(path.join(configDir, relativePath), { force: true });
|
|
76
77
|
}
|
|
77
78
|
export async function readObservedConfigBytes(configDir) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
return parts.join("\n");
|
|
79
|
+
const central = path.join(configDir, MAIN_CONFIG);
|
|
80
|
+
const inputs = path.join(configDir, INPUTS_FILE);
|
|
81
|
+
const codex = existsSync(inputs) ? (splitInputBlocks(await readFile(inputs, "utf8")).get(CODEX_INPUT_KEY) ?? "") : "";
|
|
82
|
+
return [existsSync(central) ? await readFile(central, "utf8") : "", codex].join("\n");
|
|
84
83
|
}
|