@nurix/nustack 0.23.7 → 0.23.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/telemetry.js +37 -5
- package/dist/index.js +7 -2
- package/dist/lib/filebeat_installer.js +5 -1
- package/dist/lib/nustack_client.js +4 -2
- package/dist/lib/session_telemetry.js +108 -40
- package/dist/lib/telemetry/config_renderer.js +4 -4
- package/dist/lib/telemetry/environment.js +2 -0
- package/dist/lib/telemetry/health_report.js +10 -3
- package/dist/lib/telemetry/part_state.js +168 -0
- package/package.json +2 -2
|
@@ -6,7 +6,11 @@ import { getBindingByRepositoryRoot, listActiveBindings, setBindingTelemetry, }
|
|
|
6
6
|
import { upsertFilebeatState } from "../lib/local_store/filebeat_state.js";
|
|
7
7
|
import { ensureFilebeatBinary } from "../lib/filebeat_installer.js";
|
|
8
8
|
import { collectTelemetryStatus } from "../lib/telemetry/health_report.js";
|
|
9
|
-
import { ensureBindingTelemetry, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
|
|
9
|
+
import { ensureBindingTelemetry, reconcileAllBindings, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
|
|
10
|
+
import { readPartState, telemetryReconcileLockPath } from "../lib/telemetry/part_state.js";
|
|
11
|
+
import { TELEMETRY_CHILD_VARIABLE } from "../lib/telemetry/environment.js";
|
|
12
|
+
import { isFlagSet } from "../lib/records/environment.js";
|
|
13
|
+
import { acquireDrainLock, releaseDrainLock } from "../lib/records/drain.js";
|
|
10
14
|
import { resolveRepository } from "../lib/git-sync/core/repository.js";
|
|
11
15
|
function resolveTargetBinding(store, options) {
|
|
12
16
|
if (options.device)
|
|
@@ -28,13 +32,37 @@ export async function runTelemetry(action, options) {
|
|
|
28
32
|
const store = await openLocalStore();
|
|
29
33
|
try {
|
|
30
34
|
const verbose = { localVerbose: options.localVerbose === true };
|
|
35
|
+
if (action === "reconcile") {
|
|
36
|
+
const isChild = isFlagSet(TELEMETRY_CHILD_VARIABLE);
|
|
37
|
+
const lockPath = telemetryReconcileLockPath();
|
|
38
|
+
if (isChild && !acquireDrainLock(lockPath))
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
const result = await reconcileAllBindings({ baseUrl: resolveNuStackUrl(options), store });
|
|
42
|
+
if (options.quiet)
|
|
43
|
+
return;
|
|
44
|
+
if (json) {
|
|
45
|
+
printJsonSuccess("telemetry reconcile", { scope: "device", ...result });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
p.intro("nustack telemetry reconcile");
|
|
49
|
+
p.log.success(`Reconciled ${result.reconciled} folder${result.reconciled === 1 ? "" : "s"}; parts on ${result.parts.on}, off ${result.parts.off}, unknown ${result.parts.unknown}${result.conflict ? "; two telemetry destinations conflict, so nothing ships" : ""}.`);
|
|
50
|
+
p.outro("Telemetry reconciled.");
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (isChild)
|
|
54
|
+
releaseDrainLock(lockPath);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
31
58
|
if (action === "status") {
|
|
32
59
|
const status = await collectTelemetryStatus(store, undefined, verbose);
|
|
33
60
|
if (json) {
|
|
34
61
|
printJsonSuccess("telemetry status", status);
|
|
35
62
|
return;
|
|
36
63
|
}
|
|
37
|
-
const shipping = status.folders.filter((f) => f.enabled).length;
|
|
64
|
+
const shipping = status.folders.filter((f) => f.enabled && f.shipping).length;
|
|
65
|
+
const partsOff = status.folders.filter((f) => f.partState === "off").length;
|
|
38
66
|
p.intro("nustack telemetry status");
|
|
39
67
|
p.note([
|
|
40
68
|
`telemetry_enabled: ${status.telemetryEnabled}`,
|
|
@@ -45,6 +73,7 @@ export async function runTelemetry(action, options) {
|
|
|
45
73
|
`install state: ${status.filebeatState}${status.lastErrorCode ? ` (${status.lastErrorCode})` : ""}`,
|
|
46
74
|
`config version: ${status.desiredConfigVersion}`,
|
|
47
75
|
`folders: ${shipping} of ${status.folders.length} shipping`,
|
|
76
|
+
`telemetry part: ${partsOff === 0 ? "on for every folder's project" : `off for ${partsOff} folder${partsOff === 1 ? "" : "s"}`}`,
|
|
48
77
|
`last cloud report: ${status.lastHealthAtMs ? new Date(status.lastHealthAtMs).toISOString() : "never"}`,
|
|
49
78
|
].join("\n"), "Telemetry");
|
|
50
79
|
p.outro("Telemetry status.");
|
|
@@ -99,9 +128,12 @@ export async function runTelemetry(action, options) {
|
|
|
99
128
|
return;
|
|
100
129
|
}
|
|
101
130
|
p.intro(`nustack telemetry ${action}`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
131
|
+
const isPartOff = readPartState().projects[target.projectId]?.state === "off";
|
|
132
|
+
p.log.success(enabled && isPartOff
|
|
133
|
+
? `${folderLabel(target)} is switched on, but the tenant's telemetry part is off for this project; this folder ships when it is switched back on.`
|
|
134
|
+
: enabled
|
|
135
|
+
? `${folderLabel(target)} is shipping. Other folders are unchanged.`
|
|
136
|
+
: `${folderLabel(target)} has stopped shipping; nothing it records while off will ever ship. Other folders are unchanged.`);
|
|
105
137
|
p.outro(`Run \`nustack telemetry ${enabled ? "disable" : "enable"}\` here to reverse it.`);
|
|
106
138
|
return;
|
|
107
139
|
}
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
|
|
|
34
34
|
import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
|
|
35
35
|
import { registerReviewCommands } from "./commands/review.js";
|
|
36
36
|
import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
|
|
37
|
+
import { isPartCheckStale, startTelemetryReconcile } from "./lib/telemetry/part_state.js";
|
|
37
38
|
import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
|
|
38
39
|
import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
|
|
39
40
|
import { printJsonError } from "./lib/json_output.js";
|
|
@@ -180,14 +181,15 @@ device
|
|
|
180
181
|
.action(async (deviceId, options) => runDeviceRevoke(deviceId, options, program.opts()));
|
|
181
182
|
program
|
|
182
183
|
.command("telemetry [action]")
|
|
183
|
-
.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")
|
|
184
|
+
.description("Inspect or control telemetry (status | enable | disable | reinstall | output | reconcile). 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; reconcile asks the server for each project's telemetry part")
|
|
184
185
|
.option("--json", "emit the versioned machine envelope")
|
|
185
186
|
.option("--device", "move this machine's switch instead of the current folder's")
|
|
186
187
|
.option("--local-verbose", "include each folder's absolute path (local consumers only — never pipe into a log)")
|
|
187
188
|
.option("--elastic-url <url>", "output: the Elastic endpoint (default NUSTACK_ELASTIC_URL)")
|
|
188
189
|
.option("--index <name>", "output: the index events are written to (default claude-sessions)")
|
|
190
|
+
.option("--quiet", "reconcile: print nothing")
|
|
189
191
|
.action(async (action, options) => {
|
|
190
|
-
const allowed = ["status", "enable", "disable", "reinstall", "output"];
|
|
192
|
+
const allowed = ["status", "enable", "disable", "reinstall", "output", "reconcile"];
|
|
191
193
|
const chosen = (allowed.includes(action ?? "") ? action : "status");
|
|
192
194
|
return runTelemetry(chosen, {
|
|
193
195
|
...program.opts(),
|
|
@@ -196,6 +198,7 @@ program
|
|
|
196
198
|
localVerbose: options.localVerbose,
|
|
197
199
|
elasticUrl: options.elasticUrl,
|
|
198
200
|
index: options.index,
|
|
201
|
+
quiet: options.quiet,
|
|
199
202
|
});
|
|
200
203
|
});
|
|
201
204
|
const toLimit = (value) => Number.parseInt(value, 10);
|
|
@@ -245,6 +248,8 @@ program.hook("preAction", async (_program, action) => {
|
|
|
245
248
|
const group = action.parent?.name() ?? "";
|
|
246
249
|
if (group !== "records" && !RECORD_ALIAS_GROUPS.includes(group))
|
|
247
250
|
await startDrainIfDue(program.opts());
|
|
251
|
+
if (action.name() !== "telemetry" && isPartCheckStale())
|
|
252
|
+
startTelemetryReconcile({ nustackUrl: program.opts().nustackUrl });
|
|
248
253
|
});
|
|
249
254
|
const docs = program.command("docs").description("Work with a Project's Documents");
|
|
250
255
|
docs
|
|
@@ -6,6 +6,7 @@ import { existsSync } from "node:fs";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { nustackDir } from "./nustack_store.js";
|
|
9
|
+
import { FILEBEAT_SERVICE_LABEL_VARIABLE } from "./telemetry/environment.js";
|
|
9
10
|
const FILEBEAT_VERSION = process.env.NUSTACK_FILEBEAT_VERSION || "8.15.3";
|
|
10
11
|
export function desiredFilebeatVersion() {
|
|
11
12
|
return FILEBEAT_VERSION;
|
|
@@ -106,7 +107,10 @@ export function observedFilebeatVersion() {
|
|
|
106
107
|
return null;
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
|
-
const SERVICE_LABEL =
|
|
110
|
+
export const SERVICE_LABEL = (() => {
|
|
111
|
+
const override = process.env[FILEBEAT_SERVICE_LABEL_VARIABLE]?.trim() ?? "";
|
|
112
|
+
return /^[a-z0-9][a-z0-9.-]{2,127}$/.test(override) ? override : "co.nurix.nustack.filebeat";
|
|
113
|
+
})();
|
|
110
114
|
function run(cmd, args) {
|
|
111
115
|
try {
|
|
112
116
|
execFileSync(cmd, args, { stdio: "ignore" });
|
|
@@ -21,8 +21,10 @@ const KEYS_PLANE = "the credential plane (/api/v1/keys)";
|
|
|
21
21
|
export function apiError(payload, status, label, plane) {
|
|
22
22
|
if (status === 401 && !envToken())
|
|
23
23
|
return Object.assign(new Error(deadCredentialCopy("revoked")), { code: "SIGNED_OUT" });
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const envelope = typeof payload?.error === "object" ? payload.error : undefined;
|
|
25
|
+
const code = typeof envelope?.code === "string" ? envelope.code : "UNEXPECTED";
|
|
26
|
+
const details = envelope?.details && typeof envelope.details === "object" && !Array.isArray(envelope.details) ? envelope.details : undefined;
|
|
27
|
+
return Object.assign(new Error(errorMessage(payload, status, label, plane)), details ? { code, details } : { code });
|
|
26
28
|
}
|
|
27
29
|
export function errorMessage(payload, status, label, plane) {
|
|
28
30
|
if (plane && status === 404 && typeof payload?.error === "string") {
|
|
@@ -14,8 +14,9 @@ import { INPUTS_FILE, LOCAL_OUTPUT_HEADER, renderCentralConfig, renderInputsFile
|
|
|
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";
|
|
17
|
+
import { DEFAULT_SESSION_INDEX, isSameOutput, machineVerdict, partAnswerOf, readPartState, recordPartState, } from "./telemetry/part_state.js";
|
|
17
18
|
const TELEMETRY_SERVICE = "elastic-observability";
|
|
18
|
-
const DEFAULT_INDEX =
|
|
19
|
+
const DEFAULT_INDEX = DEFAULT_SESSION_INDEX;
|
|
19
20
|
const NO_OP_LOG = () => { };
|
|
20
21
|
function defaultSeams() {
|
|
21
22
|
return {
|
|
@@ -123,53 +124,68 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
123
124
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
124
125
|
return;
|
|
125
126
|
}
|
|
126
|
-
let
|
|
127
|
+
let brokerError = null;
|
|
127
128
|
const credential = accessToken
|
|
128
129
|
? await fetchBrokeredCredential(baseUrl, accessToken, {
|
|
129
130
|
projectId: binding.projectId,
|
|
130
131
|
service: TELEMETRY_SERVICE,
|
|
131
132
|
}).catch((error) => {
|
|
132
|
-
|
|
133
|
+
brokerError = error;
|
|
133
134
|
return null;
|
|
134
135
|
})
|
|
135
136
|
: null;
|
|
136
|
-
|
|
137
|
+
const brokerFailure = brokerError === null ? null : message(brokerError);
|
|
138
|
+
const answer = accessToken ? partAnswerOf(binding.organizationId, { credential, error: brokerError }) : null;
|
|
139
|
+
const parts = answer ? recordPartState({ projects: { [binding.projectId]: answer } }) : readPartState();
|
|
140
|
+
const candidate = credential?.url && answer ? { destination: answer.destination ?? "nurix", url: credential.url, index: answer.index ?? DEFAULT_INDEX } : null;
|
|
141
|
+
const verdict = machineVerdict(bindingsOf(store, binding.id, true), parts, candidate);
|
|
142
|
+
const output = verdict.selection.output;
|
|
143
|
+
if (verdict.selection.conflict) {
|
|
144
|
+
recordPartState({ conflict: true });
|
|
145
|
+
await applyInputs(store, binding.id, verdict, deviceRef, userId, seams);
|
|
146
|
+
disableActivity(store, binding.id);
|
|
147
|
+
recordDegraded(store, "destination_conflict");
|
|
148
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!output) {
|
|
152
|
+
recordPartState({ conflict: false });
|
|
153
|
+
if (answer?.state === "off") {
|
|
154
|
+
await applyInputs(store, binding.id, verdict, deviceRef, userId, seams);
|
|
155
|
+
disableActivity(store, binding.id);
|
|
156
|
+
}
|
|
137
157
|
const unserved = brokerFailure !== null && brokerFailure.includes("does not serve");
|
|
138
158
|
recordDegraded(store, unserved ? "credential_plane_unserved" : "no_output_credential");
|
|
139
159
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
140
160
|
return;
|
|
141
161
|
}
|
|
142
|
-
if (credential
|
|
143
|
-
const index = credential.meta?.index || DEFAULT_INDEX;
|
|
162
|
+
if (credential && candidate && isSameOutput(output, candidate)) {
|
|
144
163
|
const central = {
|
|
145
164
|
relativePath: "filebeat.yml",
|
|
146
|
-
contents: renderCentralConfig({ elkUrl:
|
|
165
|
+
contents: renderCentralConfig({ elkUrl: candidate.url, apiKey: credential.key, index: candidate.index }),
|
|
147
166
|
};
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
const live = await readFile(mainConfigPath(), "utf8").catch(() => null);
|
|
168
|
+
if (live !== central.contents) {
|
|
169
|
+
const centralApply = await applyConfigChange({
|
|
170
|
+
files: [central],
|
|
171
|
+
scope: "central",
|
|
172
|
+
context: centralReloadContext(seams, filebeatDir()),
|
|
173
|
+
});
|
|
174
|
+
if (!centralApply.ok) {
|
|
175
|
+
recordDegraded(store, centralApply.errorCode);
|
|
176
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
157
179
|
}
|
|
158
180
|
}
|
|
181
|
+
recordPartState({ output, conflict: false });
|
|
159
182
|
if (!seams.serviceRegistered())
|
|
160
183
|
await seams.registerService();
|
|
161
|
-
|
|
162
|
-
await removeOrphanInputs();
|
|
163
|
-
const inputsApply = await applyConfigChange({
|
|
164
|
-
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
165
|
-
scope: "inputs",
|
|
166
|
-
context: seams.reloadContext(filebeatDir()),
|
|
167
|
-
});
|
|
168
|
-
if (!inputsApply.ok) {
|
|
169
|
-
recordDegraded(store, inputsApply.errorCode);
|
|
184
|
+
if (!(await applyInputs(store, binding.id, verdict, deviceRef, userId, seams, binding))) {
|
|
170
185
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
171
186
|
return;
|
|
172
187
|
}
|
|
188
|
+
const isShipping = verdict.shipping.has(binding.id);
|
|
173
189
|
for (const discovered of discoverProviderBindings(binding.repositoryRoot)) {
|
|
174
190
|
upsertActivityBinding(store, {
|
|
175
191
|
id: activityRowId(binding.id, discovered.provider, discovered.pathFingerprint),
|
|
@@ -177,7 +193,7 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
177
193
|
provider: discovered.provider,
|
|
178
194
|
providerProjectPath: discovered.absolutePath,
|
|
179
195
|
pathFingerprint: discovered.pathFingerprint,
|
|
180
|
-
state: discovered.state,
|
|
196
|
+
state: isShipping ? discovered.state : "disabled",
|
|
181
197
|
});
|
|
182
198
|
}
|
|
183
199
|
upsertFilebeatState(store, {
|
|
@@ -226,6 +242,7 @@ export async function setLocalOutput(opts) {
|
|
|
226
242
|
recordDegraded(store, centralApply.errorCode);
|
|
227
243
|
return { ok: false, errorCode: centralApply.errorCode };
|
|
228
244
|
}
|
|
245
|
+
recordPartState({ output: { destination: "local", url: opts.elkUrl, index: opts.index || DEFAULT_INDEX }, conflict: false });
|
|
229
246
|
if (!seams.serviceRegistered())
|
|
230
247
|
await seams.registerService();
|
|
231
248
|
upsertFilebeatState(store, {
|
|
@@ -251,7 +268,8 @@ export async function removeBindingTelemetry(opts) {
|
|
|
251
268
|
try {
|
|
252
269
|
const device = readDeviceState(store);
|
|
253
270
|
await removeOrphanInputs();
|
|
254
|
-
const
|
|
271
|
+
const verdict = machineVerdict(bindingsOf(store, bindingId, false), readPartState());
|
|
272
|
+
const inputs = renderInputs(store, { id: bindingId, enabled: false }, device?.deviceRef ?? "", device?.activeUserId ?? "", verdict);
|
|
255
273
|
if (inputs === null) {
|
|
256
274
|
await removeInputFile(filebeatDir(), INPUTS_FILE);
|
|
257
275
|
}
|
|
@@ -266,20 +284,70 @@ export async function removeBindingTelemetry(opts) {
|
|
|
266
284
|
recordDegraded(store, applied.errorCode);
|
|
267
285
|
}
|
|
268
286
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
287
|
+
disableActivity(store, bindingId);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function disableActivity(store, bindingId) {
|
|
293
|
+
for (const row of listActivityBindings(store, bindingId)) {
|
|
294
|
+
upsertActivityBinding(store, {
|
|
295
|
+
id: row.id,
|
|
296
|
+
bindingId: row.bindingId,
|
|
297
|
+
provider: row.provider,
|
|
298
|
+
providerProjectPath: row.providerProjectPath,
|
|
299
|
+
pathFingerprint: row.pathFingerprint,
|
|
300
|
+
state: "disabled",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function bindingsOf(store, bindingId, folderEnabled) {
|
|
305
|
+
return listActiveBindings(store).map((b) => ({ bindingId: b.id, projectId: b.projectId, folderEnabled: b.id === bindingId ? folderEnabled : b.telemetryEnabled }));
|
|
306
|
+
}
|
|
307
|
+
async function applyInputs(store, bindingId, verdict, deviceRef, userId, seams, binding) {
|
|
308
|
+
const inputs = renderInputs(store, { id: bindingId, binding, enabled: true }, deviceRef, userId, verdict) ?? "";
|
|
309
|
+
await removeOrphanInputs();
|
|
310
|
+
const inputsApply = await applyConfigChange({
|
|
311
|
+
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
312
|
+
scope: "inputs",
|
|
313
|
+
context: seams.reloadContext(filebeatDir()),
|
|
314
|
+
});
|
|
315
|
+
if (!inputsApply.ok)
|
|
316
|
+
recordDegraded(store, inputsApply.errorCode);
|
|
317
|
+
return inputsApply.ok;
|
|
318
|
+
}
|
|
319
|
+
export async function reconcileAllBindings(opts) {
|
|
320
|
+
const { store, baseUrl } = opts;
|
|
321
|
+
const bindings = listActiveBindings(store);
|
|
322
|
+
let reconciled = 0;
|
|
323
|
+
try {
|
|
324
|
+
const machine = readFilebeatState(store);
|
|
325
|
+
if (!optedOutByEnv() && (!machine || machine.telemetryEnabled)) {
|
|
326
|
+
for (const binding of bindings) {
|
|
327
|
+
await ensureBindingTelemetry({ baseUrl, store, binding, seams: opts.seams });
|
|
328
|
+
reconciled += 1;
|
|
329
|
+
}
|
|
278
330
|
}
|
|
279
331
|
}
|
|
332
|
+
finally {
|
|
333
|
+
recordPartStateBestEffort();
|
|
334
|
+
}
|
|
335
|
+
const parts = readPartState();
|
|
336
|
+
return { reconciled, parts: countParts(bindings, parts), conflict: parts.conflict };
|
|
337
|
+
}
|
|
338
|
+
function recordPartStateBestEffort() {
|
|
339
|
+
try {
|
|
340
|
+
recordPartState();
|
|
341
|
+
}
|
|
280
342
|
catch {
|
|
281
343
|
}
|
|
282
344
|
}
|
|
345
|
+
function countParts(bindings, parts) {
|
|
346
|
+
const counts = { on: 0, off: 0, unknown: 0 };
|
|
347
|
+
for (const projectId of new Set(bindings.map((b) => b.projectId)))
|
|
348
|
+
counts[parts.projects[projectId]?.state ?? "unknown"] += 1;
|
|
349
|
+
return counts;
|
|
350
|
+
}
|
|
283
351
|
export async function switchMachineOff(opts) {
|
|
284
352
|
const seams = { ...defaultSeams(), ...opts.seams };
|
|
285
353
|
const { store } = opts;
|
|
@@ -310,15 +378,15 @@ export async function switchMachineOff(opts) {
|
|
|
310
378
|
upsertFilebeatState(store, { processState: "stopped" });
|
|
311
379
|
return "stopped";
|
|
312
380
|
}
|
|
313
|
-
function renderInputs(store, reconciled, deviceRef, userId) {
|
|
381
|
+
function renderInputs(store, reconciled, deviceRef, userId, verdict) {
|
|
314
382
|
const byId = new Map();
|
|
315
383
|
for (const b of listActiveBindings(store)) {
|
|
316
|
-
byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled });
|
|
384
|
+
byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled && verdict.shipping.has(b.id) });
|
|
317
385
|
}
|
|
318
386
|
const known = reconciled.binding ? toTelemetryBinding(reconciled.binding, deviceRef, userId) : byId.get(reconciled.id)?.binding;
|
|
319
387
|
if (known)
|
|
320
|
-
byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled });
|
|
321
|
-
return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId });
|
|
388
|
+
byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled && verdict.shipping.has(reconciled.id) });
|
|
389
|
+
return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId }, { dropAll: verdict.dropCodex });
|
|
322
390
|
}
|
|
323
391
|
async function removeOrphanInputs() {
|
|
324
392
|
const inputsDirPath = path.join(filebeatDir(), "inputs.d");
|
|
@@ -139,10 +139,10 @@ export function renderBindingInput(binding, enabled = true) {
|
|
|
139
139
|
});
|
|
140
140
|
return enabled ? block : block.replace("\n processors:\n", `\n processors:\n${SWITCHED_OFF}`);
|
|
141
141
|
}
|
|
142
|
-
export function renderCodexSharedInput(entries, device) {
|
|
142
|
+
export function renderCodexSharedInput(entries, device, options = {}) {
|
|
143
143
|
const ordered = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
144
144
|
const sorted = ordered.filter((entry) => entry.enabled).map((entry) => entry.binding);
|
|
145
|
-
const drops = sorted.length === 0 ? SWITCHED_OFF : "";
|
|
145
|
+
const drops = sorted.length === 0 || options.dropAll === true ? SWITCHED_OFF : "";
|
|
146
146
|
const conditionals = sorted
|
|
147
147
|
.map((binding) => `
|
|
148
148
|
- if:
|
|
@@ -183,10 +183,10 @@ ${drops} - add_fields:
|
|
|
183
183
|
ignore_missing: true
|
|
184
184
|
`;
|
|
185
185
|
}
|
|
186
|
-
export function renderInputsFile(entries, device) {
|
|
186
|
+
export function renderInputsFile(entries, device, options = {}) {
|
|
187
187
|
const sorted = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
188
188
|
const blocks = sorted.map((entry) => renderBindingInput(entry.binding, entry.enabled));
|
|
189
|
-
return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device);
|
|
189
|
+
return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device, options);
|
|
190
190
|
}
|
|
191
191
|
export function splitInputBlocks(contents) {
|
|
192
192
|
const starts = [...contents.matchAll(BLOCK_MARKER)].map((match) => ({
|
|
@@ -9,6 +9,7 @@ import { REDACTION_POLICY_VERSION, } from "./redaction_policy.js";
|
|
|
9
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
|
+
import { machineVerdict, readPartState } from "./part_state.js";
|
|
12
13
|
export function defaultProbes() {
|
|
13
14
|
return {
|
|
14
15
|
binaryPresent: () => existsSync(filebeatBinPath()),
|
|
@@ -36,7 +37,11 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
36
37
|
const userId = device?.activeUserId ?? "";
|
|
37
38
|
const folders = [];
|
|
38
39
|
const activity = [];
|
|
39
|
-
|
|
40
|
+
const bindings = listActiveBindings(store);
|
|
41
|
+
const parts = readPartState();
|
|
42
|
+
const verdict = machineVerdict(bindings.map((b) => ({ bindingId: b.id, projectId: b.projectId, folderEnabled: b.telemetryEnabled })), parts);
|
|
43
|
+
for (const binding of bindings) {
|
|
44
|
+
const isRendered = verdict.shipping.has(binding.id);
|
|
40
45
|
const rendered = renderBindingInput({
|
|
41
46
|
bindingId: binding.id,
|
|
42
47
|
repoRoot: binding.repositoryRoot,
|
|
@@ -57,9 +62,11 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
57
62
|
desiredConfigHash: desired,
|
|
58
63
|
observedConfigHash: observed,
|
|
59
64
|
redactionPolicyVersion: REDACTION_POLICY_VERSION,
|
|
65
|
+
partState: parts.projects[binding.projectId]?.state ?? "unknown",
|
|
66
|
+
shipping: isRendered && verdict.selection.output !== null,
|
|
60
67
|
state: folderState({
|
|
61
68
|
telemetryEnabled,
|
|
62
|
-
folderEnabled: binding.telemetryEnabled,
|
|
69
|
+
folderEnabled: binding.telemetryEnabled && isRendered,
|
|
63
70
|
binaryPresent,
|
|
64
71
|
desired,
|
|
65
72
|
observed,
|
|
@@ -75,7 +82,7 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
75
82
|
providerProjectKey: discovered.providerProjectKey,
|
|
76
83
|
pathFingerprint: discovered.pathFingerprint,
|
|
77
84
|
discoveryMethod: discovered.discoveryMethod,
|
|
78
|
-
state: !telemetryEnabled || !binding.telemetryEnabled ? "disabled" : baseState,
|
|
85
|
+
state: !telemetryEnabled || !binding.telemetryEnabled || !isRendered ? "disabled" : baseState,
|
|
79
86
|
lastObservedAtMs: row?.lastObservedAtMs ?? null,
|
|
80
87
|
});
|
|
81
88
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { filebeatDir, mainConfigPath } from "../filebeat_installer.js";
|
|
6
|
+
import { locksDir } from "../local_store/paths.js";
|
|
7
|
+
import { DRAIN_CHILD_VARIABLE, isFlagSet } from "../records/environment.js";
|
|
8
|
+
import { BROKERED_OUTPUT_HEADER, LOCAL_OUTPUT_HEADER } from "./config_renderer.js";
|
|
9
|
+
import { TELEMETRY_CHILD_VARIABLE } from "./environment.js";
|
|
10
|
+
export const TELEMETRY_PART_OFF_REASON = "telemetry_part_off";
|
|
11
|
+
export const TELEMETRY_PART_DETAIL = "telemetryPart";
|
|
12
|
+
export const TELEMETRY_DESTINATION_DETAIL = "telemetryDestination";
|
|
13
|
+
export const DEFAULT_SESSION_INDEX = "claude-sessions";
|
|
14
|
+
export const PART_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
15
|
+
const PART_STATE_SCHEMA_VERSION = 1;
|
|
16
|
+
export function partStatePath() {
|
|
17
|
+
return path.join(filebeatDir(), "part-state.json");
|
|
18
|
+
}
|
|
19
|
+
export function telemetryReconcileLockPath() {
|
|
20
|
+
return path.join(locksDir(), "telemetry-reconcile.lock");
|
|
21
|
+
}
|
|
22
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
const stringOrNull = (value) => (typeof value === "string" && value.length > 0 ? value : null);
|
|
24
|
+
function readOutput(value) {
|
|
25
|
+
if (!isRecord(value))
|
|
26
|
+
return null;
|
|
27
|
+
const destination = value.destination;
|
|
28
|
+
if (destination !== "nurix" && destination !== "tenant" && destination !== "local")
|
|
29
|
+
return null;
|
|
30
|
+
return { destination, url: stringOrNull(value.url), index: stringOrNull(value.index) };
|
|
31
|
+
}
|
|
32
|
+
function readProject(value) {
|
|
33
|
+
if (!isRecord(value) || (value.state !== "on" && value.state !== "off") || typeof value.organizationId !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
const destination = value.destination === "nurix" || value.destination === "tenant" ? value.destination : null;
|
|
36
|
+
return { organizationId: value.organizationId, state: value.state, destination, url: stringOrNull(value.url), index: stringOrNull(value.index), checkedAt: String(value.checkedAt ?? "") };
|
|
37
|
+
}
|
|
38
|
+
function seedOutput() {
|
|
39
|
+
try {
|
|
40
|
+
const header = readFileSync(mainConfigPath(), "utf8").split("\n", 1)[0];
|
|
41
|
+
if (header === LOCAL_OUTPUT_HEADER)
|
|
42
|
+
return { destination: "local", url: null, index: null };
|
|
43
|
+
if (header === BROKERED_OUTPUT_HEADER)
|
|
44
|
+
return { destination: "nurix", url: null, index: null };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function readPartState() {
|
|
52
|
+
const empty = { schemaVersion: PART_STATE_SCHEMA_VERSION, checkedAt: null, output: null, conflict: false, projects: {} };
|
|
53
|
+
let state = empty;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(partStatePath(), "utf8"));
|
|
56
|
+
if (isRecord(parsed) && parsed.schemaVersion === PART_STATE_SCHEMA_VERSION) {
|
|
57
|
+
const projects = {};
|
|
58
|
+
for (const [projectId, entry] of Object.entries(isRecord(parsed.projects) ? parsed.projects : {})) {
|
|
59
|
+
const project = readProject(entry);
|
|
60
|
+
if (project)
|
|
61
|
+
projects[projectId] = project;
|
|
62
|
+
}
|
|
63
|
+
state = { ...empty, checkedAt: stringOrNull(parsed.checkedAt), output: readOutput(parsed.output), conflict: parsed.conflict === true, projects };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
}
|
|
68
|
+
return state.output ? state : { ...state, output: seedOutput() };
|
|
69
|
+
}
|
|
70
|
+
export function recordPartState(change = {}) {
|
|
71
|
+
const current = readPartState();
|
|
72
|
+
const projects = { ...current.projects };
|
|
73
|
+
for (const [projectId, entry] of Object.entries(change.projects ?? {})) {
|
|
74
|
+
if (entry)
|
|
75
|
+
projects[projectId] = entry;
|
|
76
|
+
else
|
|
77
|
+
delete projects[projectId];
|
|
78
|
+
}
|
|
79
|
+
const next = {
|
|
80
|
+
schemaVersion: PART_STATE_SCHEMA_VERSION,
|
|
81
|
+
checkedAt: new Date().toISOString(),
|
|
82
|
+
output: change.output === undefined ? current.output : change.output,
|
|
83
|
+
conflict: change.conflict ?? current.conflict,
|
|
84
|
+
projects,
|
|
85
|
+
};
|
|
86
|
+
const target = partStatePath();
|
|
87
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
88
|
+
const staged = `${target}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(staged, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
renameSync(staged, target);
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
export function partAnswerOf(organizationId, answer) {
|
|
94
|
+
const checkedAt = new Date().toISOString();
|
|
95
|
+
if (answer.credential) {
|
|
96
|
+
const index = typeof answer.credential.meta?.index === "string" && answer.credential.meta.index ? answer.credential.meta.index : DEFAULT_SESSION_INDEX;
|
|
97
|
+
const destination = answer.credential.meta?.destination === "tenant" ? "tenant" : "nurix";
|
|
98
|
+
return { organizationId, state: "on", destination, url: answer.credential.url, index, checkedAt };
|
|
99
|
+
}
|
|
100
|
+
const details = isRecord(answer.error) && isRecord(answer.error.details) ? answer.error.details : null;
|
|
101
|
+
if (!details)
|
|
102
|
+
return null;
|
|
103
|
+
if (details.reason === TELEMETRY_PART_OFF_REASON)
|
|
104
|
+
return { organizationId, state: "off", destination: null, url: null, index: null, checkedAt };
|
|
105
|
+
if (details[TELEMETRY_PART_DETAIL] !== "on")
|
|
106
|
+
return null;
|
|
107
|
+
const named = details[TELEMETRY_DESTINATION_DETAIL];
|
|
108
|
+
return { organizationId, state: "on", destination: named === "nurix" || named === "tenant" ? named : null, url: null, index: null, checkedAt };
|
|
109
|
+
}
|
|
110
|
+
export const isSameOutput = (a, b) => a !== null && b !== null && a.destination === b.destination && a.url === b.url && a.index === b.index;
|
|
111
|
+
export function matchesOutput(entry, output) {
|
|
112
|
+
if (!output || entry?.state === "off")
|
|
113
|
+
return false;
|
|
114
|
+
if (output.destination !== "tenant")
|
|
115
|
+
return entry?.destination !== "tenant";
|
|
116
|
+
return entry?.state === "on" && entry.destination === "tenant" && entry.url === output.url && entry.index === output.index;
|
|
117
|
+
}
|
|
118
|
+
export function selectMachineOutput(input) {
|
|
119
|
+
const live = input.bindings.filter((binding) => binding.folderEnabled && input.projects[binding.projectId]?.state !== "off");
|
|
120
|
+
const tenantOutputs = new Map();
|
|
121
|
+
for (const binding of live) {
|
|
122
|
+
const entry = input.projects[binding.projectId];
|
|
123
|
+
if (entry?.destination === "tenant" && entry.url !== null)
|
|
124
|
+
tenantOutputs.set(`${entry.url}\n${entry.index}`, { destination: "tenant", url: entry.url, index: entry.index });
|
|
125
|
+
}
|
|
126
|
+
if (tenantOutputs.size > 1)
|
|
127
|
+
return { output: input.current, shippingBindingIds: [], conflict: true };
|
|
128
|
+
const tenant = [...tenantOutputs.values()][0] ?? null;
|
|
129
|
+
let output = input.current;
|
|
130
|
+
if (tenant && (isSameOutput(tenant, input.candidate) || isSameOutput(tenant, input.current)))
|
|
131
|
+
output = tenant;
|
|
132
|
+
else if (!tenant && input.candidate?.destination === "nurix")
|
|
133
|
+
output = input.candidate;
|
|
134
|
+
const shippingBindingIds = live.filter((binding) => matchesOutput(input.projects[binding.projectId], output)).map((binding) => binding.bindingId);
|
|
135
|
+
return { output, shippingBindingIds, conflict: false };
|
|
136
|
+
}
|
|
137
|
+
export function machineVerdict(bindings, parts, candidate = null) {
|
|
138
|
+
const selection = selectMachineOutput({ bindings, projects: parts.projects, current: parts.output, candidate });
|
|
139
|
+
const isOff = (projectId) => parts.projects[projectId]?.state === "off";
|
|
140
|
+
const live = bindings.filter((binding) => binding.folderEnabled && !isOff(binding.projectId)).map((binding) => binding.bindingId);
|
|
141
|
+
const shipping = new Set(selection.conflict ? [] : selection.output === null ? live : selection.shippingBindingIds);
|
|
142
|
+
const dropCodex = selection.conflict || selection.output?.destination === "tenant" || bindings.some((binding) => isOff(binding.projectId));
|
|
143
|
+
return { selection, shipping, dropCodex };
|
|
144
|
+
}
|
|
145
|
+
export function isPartCheckStale(env = process.env, now = Date.now()) {
|
|
146
|
+
try {
|
|
147
|
+
if (isFlagSet(DRAIN_CHILD_VARIABLE, env) || isFlagSet(TELEMETRY_CHILD_VARIABLE, env) || env.NUSTACK_TELEMETRY === "off")
|
|
148
|
+
return false;
|
|
149
|
+
if (!statSync(mainConfigPath(), { throwIfNoEntry: false }))
|
|
150
|
+
return false;
|
|
151
|
+
const file = statSync(partStatePath(), { throwIfNoEntry: false });
|
|
152
|
+
return !file || now - file.mtimeMs > PART_CHECK_INTERVAL_MS;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function startTelemetryReconcile(options = {}) {
|
|
159
|
+
try {
|
|
160
|
+
const entry = fileURLToPath(new URL("../../index.js", import.meta.url));
|
|
161
|
+
const args = [entry, ...(options.nustackUrl ? ["--nustack-url", options.nustackUrl] : []), "telemetry", "reconcile", "--quiet"];
|
|
162
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", env: { ...process.env, [TELEMETRY_CHILD_VARIABLE]: "1" } });
|
|
163
|
+
child.on("error", () => { });
|
|
164
|
+
child.unref();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
}
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.9",
|
|
4
4
|
"description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"@types/node": "^22.19.20",
|
|
36
36
|
"typescript": "^5.7.2",
|
|
37
37
|
"vitest": "^4.1.11",
|
|
38
|
-
"@nurix/contracts": "2.0.1",
|
|
39
38
|
"@nurix/nustack.studio": "0.0.0",
|
|
39
|
+
"@nurix/contracts": "2.0.1",
|
|
40
40
|
"@nurix/nustack.ui": "0.0.0"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|