@camstack/system 1.2.217 → 1.2.219
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/addon-runner.js +9 -6
- package/dist/addon-runner.mjs +9 -6
- package/dist/addon-utils.js +2 -2
- package/dist/addon-utils.mjs +2 -2
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +2 -2
- package/dist/builtins/local-network/local-network.addon.mjs +2 -2
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/gpu-probe.d.ts +18 -0
- package/dist/builtins/native-metrics/native-metrics-provider.d.ts +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.d.ts +24 -8
- package/dist/builtins/native-metrics/native-metrics.addon.js +1318 -1098
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1318 -1101
- package/dist/builtins/native-metrics/proc-process-table.d.ts +25 -0
- package/dist/builtins/platform-probe/index.js +3 -3
- package/dist/builtins/platform-probe/index.mjs +3 -3
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +23 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -0
- package/dist/builtins/sqlite-storage/wal-checkpoint-worker.d.ts +1 -0
- package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +70 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{custom-action-registry-F__gp_VX.mjs → custom-action-registry-B6gOtUWA.mjs} +19 -0
- package/dist/{custom-action-registry-jY0NOZK8.js → custom-action-registry-DKWhaWL1.js} +19 -0
- package/dist/{dist-C85OOASj.js → dist-B17W9ngu.js} +118 -3
- package/dist/{dist-DtDKMSeG.mjs → dist-Cyz5_i7z.mjs} +113 -4
- package/dist/index.d.ts +9 -2
- package/dist/index.js +39 -36
- package/dist/index.mjs +23 -38
- package/dist/kernel/array-buffer-census.d.ts +122 -0
- package/dist/kernel/custom-action-registry.d.ts +10 -1
- package/dist/kernel/heap-watch.d.ts +39 -1
- package/dist/kernel/moleculer/mesh-queue-report.d.ts +51 -0
- package/dist/kernel/outbound-queue-watch.d.ts +94 -0
- package/dist/kernel/socket-plane-report.d.ts +75 -4
- package/dist/kernel/transport/cap-routing-hints.d.ts +21 -0
- package/dist/kernel/transport/child-cap-protocol.d.ts +43 -3
- package/dist/kernel/transport/frame-codec.d.ts +49 -0
- package/dist/kernel/transport/local-child-client.d.ts +25 -2
- package/dist/kernel/transport/local-child-registry.d.ts +36 -1
- package/dist/kernel/transport/local-transport.d.ts +45 -2
- package/dist/kernel/transport/socket-channel.d.ts +19 -1
- package/dist/{manifest-system-deps-726RBOX7.mjs → manifest-system-deps-C7dihwWT.mjs} +1604 -788
- package/dist/{manifest-system-deps-DvY1bY_n.js → manifest-system-deps-g_nK0xsF.js} +1607 -683
- package/dist/process/proc-stat.d.ts +75 -0
- package/dist/process/resource-monitor.d.ts +9 -1
- package/dist/{resource-monitor-CdnzxBLP.js → resource-monitor-LVE1BoLs.js} +42 -23
- package/dist/{resource-monitor-BWmQ5i-o.mjs → resource-monitor-Vp0bPV3U.mjs} +42 -22
- package/dist/{retired-settings-keys-Cx-lYIbU.mjs → retired-settings-keys-BCYUrdsA.mjs} +1 -1
- package/dist/{retired-settings-keys-Dfj9LA-i.js → retired-settings-keys-CjvB7Etx.js} +1 -1
- package/dist/wal-checkpoint-policy-CAcg63o-.mjs +92 -0
- package/dist/wal-checkpoint-policy-CCJZngds.js +127 -0
- package/dist/wal-checkpoint-worker.js +138 -0
- package/dist/wal-checkpoint-worker.mjs +137 -0
- package/package.json +1 -1
- package/dist/{event-loop-stall-monitor-DM9OAzy8.js → event-loop-stall-monitor-CAsHdMwP.js} +1 -1
- package/dist/{event-loop-stall-monitor-B3CFd9tM.mjs → event-loop-stall-monitor-GPrVvx14.mjs} +1 -1
- package/dist/{tls-DOTmtLCW.mjs → tls-2xbgg78V.mjs} +2 -2
- package/dist/{tls-BxQlomxd.js → tls-BFs4PzoW.js} +2 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
4
|
-
import { promisify } from "node:util";
|
|
1
|
+
import { Jt as errMsg, Ot as reducePoints, Yt as BaseAddon, cn as createEvent, kt as resolveBucketMs, rt as foldSnapshotByFunction, xn as EventCategory, xt as metricsProviderCapability } from "../../dist-Cyz5_i7z.mjs";
|
|
2
|
+
import { access, readFile, readdir } from "node:fs/promises";
|
|
5
3
|
import * as fs from "node:fs";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
4
|
+
import { constants, readFileSync } from "node:fs";
|
|
5
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
7
6
|
import * as os from "node:os";
|
|
7
|
+
import { platform } from "node:os";
|
|
8
|
+
import { promisify } from "node:util";
|
|
8
9
|
//#region src/builtins/native-metrics/cgroup-memory.ts
|
|
9
10
|
/** `readFileSync`, with every failure flattened to `null`. */
|
|
10
11
|
var nodeCgroupFileReader = { read(path) {
|
|
@@ -1086,1118 +1087,1376 @@ var ContainerMemoryStore = class {
|
|
|
1086
1087
|
}
|
|
1087
1088
|
};
|
|
1088
1089
|
//#endregion
|
|
1089
|
-
//#region src/builtins/native-metrics/
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1090
|
+
//#region src/builtins/native-metrics/load-series-store.ts
|
|
1091
|
+
/**
|
|
1092
|
+
* @durable class=audit owner=metrics-native
|
|
1093
|
+
* write="one row per process per sample, appended in ONE transaction per
|
|
1094
|
+
* sample (`settingsStore.insertMany`) by the HUB's `native-metrics`, off its
|
|
1095
|
+
* subscription to `metrics.node-processes-snapshot`. Emission is
|
|
1096
|
+
* fixed-cadence, so the write rate is deterministic: nodes x processes /
|
|
1097
|
+
* cadence, 7.6 rows/s on the live fleet at the 10 s default. Only a sample
|
|
1098
|
+
* the in-memory ring ACCEPTED is written, which is what makes the append
|
|
1099
|
+
* idempotent without a unique key."
|
|
1100
|
+
* retention="TWO bounds, swept together at most once a minute. By AGE, the
|
|
1101
|
+
* operator's `loadSeriesRetentionHours` (default 6, the intention); by ROWS,
|
|
1102
|
+
* DEFAULT_MAX_ROWS (the guarantee, which bites regardless of the age knob).
|
|
1103
|
+
* Both prune oldest-first through a bounded page — never a full scan — and
|
|
1104
|
+
* the row cap logs when it bites. Losing the table loses the cold window
|
|
1105
|
+
* only: the ring keeps serving the last hour and the charts keep drawing."
|
|
1106
|
+
*/
|
|
1107
|
+
var LOAD_SERIES_COLLECTION = "metrics:node-load-samples";
|
|
1108
|
+
/**
|
|
1109
|
+
* Hard ceiling on the table, in ROWS.
|
|
1110
|
+
*
|
|
1111
|
+
* The guarantee behind the retention knob. Sized against the shipped default
|
|
1112
|
+
* and against the worst configuration the knob can express:
|
|
1113
|
+
*
|
|
1114
|
+
* | cadence | retention | rows (3 nodes, 76 processes) |
|
|
1115
|
+
* | --- | --- | --- |
|
|
1116
|
+
* | 10 s | 6 h (default) | 164 160 |
|
|
1117
|
+
* | 5 s | 6 h | 328 320 |
|
|
1118
|
+
* | 5 s | 72 h | 3 939 840 — capped here |
|
|
1119
|
+
*
|
|
1120
|
+
* 500 000 rows x the measured 87 B = **41.5 MiB**, roughly 3x what the shipped
|
|
1121
|
+
* default needs and far below the 2 M rows a three-day retention would reach.
|
|
1122
|
+
* It is deliberately NOT a knob: the point of a guarantee is that it is not
|
|
1123
|
+
* subject to the same hand that raised the intention. This is the discipline
|
|
1124
|
+
* the in-memory ring already adopted (`DEFAULT_MAX_TOTAL_PROCESS_ROWS`) and
|
|
1125
|
+
* the one the RTP pre-roll rings still lack — a maximum per instance and no
|
|
1126
|
+
* budget at all for the whole, which reached 898 MB across 47 rings
|
|
1127
|
+
* (`docs/architecture/load-ledger.md` #5).
|
|
1128
|
+
*/
|
|
1129
|
+
var DEFAULT_MAX_ROWS = 5e5;
|
|
1130
|
+
var LOAD_SERIES_COLUMNS = [
|
|
1131
|
+
(
|
|
1132
|
+
/** `INTEGER PRIMARY KEY` = SQLite rowid alias: the key IS the row's address,
|
|
1133
|
+
* so it costs no separate index and no stored string. */
|
|
1134
|
+
{
|
|
1135
|
+
name: "id",
|
|
1136
|
+
type: "INTEGER",
|
|
1137
|
+
primaryKey: true,
|
|
1138
|
+
notNull: true
|
|
1139
|
+
}),
|
|
1140
|
+
{
|
|
1141
|
+
name: "nodeId",
|
|
1142
|
+
type: "TEXT",
|
|
1143
|
+
notNull: true
|
|
1144
|
+
},
|
|
1145
|
+
(
|
|
1146
|
+
/** The EMITTING node's timestamp for the sample this row belongs to. Every
|
|
1147
|
+
* row of one sample shares it — that is what makes a sample reassemblable. */
|
|
1148
|
+
{
|
|
1149
|
+
name: "atMs",
|
|
1150
|
+
type: "INTEGER",
|
|
1151
|
+
notNull: true
|
|
1152
|
+
}),
|
|
1153
|
+
{
|
|
1154
|
+
name: "pid",
|
|
1155
|
+
type: "INTEGER",
|
|
1156
|
+
notNull: true
|
|
1157
|
+
},
|
|
1158
|
+
(
|
|
1159
|
+
/** `NULL` for a process no addon owns — `root` and `system` both. */
|
|
1160
|
+
{
|
|
1161
|
+
name: "addonId",
|
|
1162
|
+
type: "TEXT"
|
|
1163
|
+
}),
|
|
1164
|
+
{
|
|
1165
|
+
name: "classification",
|
|
1166
|
+
type: "TEXT",
|
|
1167
|
+
notNull: true
|
|
1168
|
+
},
|
|
1169
|
+
(
|
|
1170
|
+
/** `ps pcpu` x 10. A LIFETIME average, not a rate — see `NodeProcess`. */
|
|
1171
|
+
{
|
|
1172
|
+
name: "cpuDeci",
|
|
1173
|
+
type: "INTEGER",
|
|
1174
|
+
notNull: true
|
|
1175
|
+
}),
|
|
1176
|
+
{
|
|
1177
|
+
name: "rssMib",
|
|
1178
|
+
type: "INTEGER",
|
|
1179
|
+
notNull: true
|
|
1180
|
+
},
|
|
1181
|
+
(
|
|
1182
|
+
/** Instantaneous main-thread CPU% x 10. `NULL` = UNKNOWN, never zero. */
|
|
1183
|
+
{
|
|
1184
|
+
name: "cpuMainDeci",
|
|
1185
|
+
type: "INTEGER"
|
|
1186
|
+
}),
|
|
1187
|
+
(
|
|
1188
|
+
/** Instantaneous V8-helper-pool CPU% x 10. `NULL` = UNKNOWN, never zero. */
|
|
1189
|
+
{
|
|
1190
|
+
name: "cpuGcDeci",
|
|
1191
|
+
type: "INTEGER"
|
|
1192
|
+
})
|
|
1193
|
+
];
|
|
1194
|
+
var LOAD_SERIES_INDEXES = [(
|
|
1195
|
+
/**
|
|
1196
|
+
* The ONE index, and it serves both jobs.
|
|
1197
|
+
*
|
|
1198
|
+
* Reads are always "this node, newer than T" — `nodeId` leads so the
|
|
1199
|
+
* equality is a range scan and `atMs` supplies the order without a sort.
|
|
1200
|
+
* Prunes are always "this node, oldest first" — the same index, walked from
|
|
1201
|
+
* the other end. A second index on `atMs` alone was measured and rejected:
|
|
1202
|
+
* it cost 23 B/row (196.0 → 172.8 with the two column drops) to serve a
|
|
1203
|
+
* cross-node prune that a per-node loop over three nodes already serves.
|
|
1204
|
+
*/
|
|
1205
|
+
{
|
|
1206
|
+
name: "idx_load_samples_node_at",
|
|
1207
|
+
columns: ["nodeId", "atMs"]
|
|
1208
|
+
})];
|
|
1209
|
+
var BYTES_PER_MIB = 1048576;
|
|
1210
|
+
/** Tenths → percent. `null` stays `null`: unknown is not zero. */
|
|
1211
|
+
function fromDeci(value) {
|
|
1212
|
+
return value === null ? null : Math.round(value) / 10;
|
|
1213
|
+
}
|
|
1214
|
+
/** Percent → tenths, lossless because both producers already round to tenths. */
|
|
1215
|
+
function toDeci(value) {
|
|
1216
|
+
return value === null ? null : Math.round(value * 10);
|
|
1217
|
+
}
|
|
1218
|
+
/** Storage row → the shape a reader gets. The ONE decode. */
|
|
1219
|
+
function rowToProcess(row) {
|
|
1220
|
+
return {
|
|
1221
|
+
pid: row.pid,
|
|
1222
|
+
addonId: row.addonId,
|
|
1223
|
+
classification: row.classification,
|
|
1224
|
+
cpuPercent: fromDeci(row.cpuDeci) ?? 0,
|
|
1225
|
+
memoryRssBytes: row.rssMib * BYTES_PER_MIB,
|
|
1226
|
+
cpuMainPercent: fromDeci(row.cpuMainDeci),
|
|
1227
|
+
cpuGcPercent: fromDeci(row.cpuGcDeci)
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
/** The shape a reader gets → the storage row. The ONE encode. */
|
|
1231
|
+
function processToRow(nodeId, atMs, process) {
|
|
1232
|
+
return {
|
|
1233
|
+
nodeId,
|
|
1234
|
+
atMs,
|
|
1235
|
+
pid: process.pid,
|
|
1236
|
+
addonId: process.addonId,
|
|
1237
|
+
classification: process.classification,
|
|
1238
|
+
cpuDeci: toDeci(process.cpuPercent) ?? 0,
|
|
1239
|
+
rssMib: Math.round(process.memoryRssBytes / BYTES_PER_MIB),
|
|
1240
|
+
cpuMainDeci: toDeci(process.cpuMainPercent),
|
|
1241
|
+
cpuGcDeci: toDeci(process.cpuGcPercent)
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
/** Group flat rows into samples, oldest-first. A sample is one `atMs`. */
|
|
1245
|
+
function rowsToSamples(rows) {
|
|
1246
|
+
const byAt = /* @__PURE__ */ new Map();
|
|
1247
|
+
for (const row of rows) {
|
|
1248
|
+
const held = byAt.get(row.atMs);
|
|
1249
|
+
if (held === void 0) byAt.set(row.atMs, [rowToProcess(row)]);
|
|
1250
|
+
else held.push(rowToProcess(row));
|
|
1102
1251
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1252
|
+
return [...byAt.entries()].toSorted((a, b) => a[0] - b[0]).map(([atMs, processes]) => ({
|
|
1253
|
+
atMs,
|
|
1254
|
+
processes
|
|
1255
|
+
}));
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The cold tier. Append-only, bounded, and never the authority on anything the
|
|
1259
|
+
* hot ring can answer.
|
|
1260
|
+
*
|
|
1261
|
+
* Every method is best-effort and says so in the log rather than throwing at
|
|
1262
|
+
* the emitter: a storage hiccup must cost a gap in a chart, never a dropped
|
|
1263
|
+
* snapshot on the bus.
|
|
1264
|
+
*/
|
|
1265
|
+
var LoadSeriesStore = class {
|
|
1266
|
+
declared = false;
|
|
1267
|
+
lastPruneAtMs = 0;
|
|
1268
|
+
store;
|
|
1269
|
+
logger;
|
|
1270
|
+
nowFn;
|
|
1271
|
+
prunePageRows;
|
|
1272
|
+
pruneIntervalMs;
|
|
1273
|
+
constructor(deps) {
|
|
1274
|
+
this.store = deps.store;
|
|
1275
|
+
this.logger = deps.logger;
|
|
1276
|
+
this.nowFn = deps.now ?? (() => Date.now());
|
|
1277
|
+
this.prunePageRows = deps.prunePageRows ?? 2e4;
|
|
1278
|
+
this.pruneIntervalMs = deps.pruneIntervalMs ?? 6e4;
|
|
1110
1279
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1280
|
+
/** Idempotently declare the collection. `false` when the store refused. */
|
|
1281
|
+
async declare() {
|
|
1282
|
+
if (this.declared) return true;
|
|
1283
|
+
try {
|
|
1284
|
+
await this.store.declareCollection.mutate({
|
|
1285
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1286
|
+
columns: [...LOAD_SERIES_COLUMNS],
|
|
1287
|
+
indexes: LOAD_SERIES_INDEXES.map((i) => ({
|
|
1288
|
+
name: i.name,
|
|
1289
|
+
columns: [...i.columns]
|
|
1290
|
+
}))
|
|
1291
|
+
});
|
|
1292
|
+
this.declared = true;
|
|
1293
|
+
return true;
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
this.logger.warn("load series declareCollection failed — nothing will be retained on disk", { meta: {
|
|
1296
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1297
|
+
error: errMsg(err)
|
|
1298
|
+
} });
|
|
1299
|
+
return false;
|
|
1116
1300
|
}
|
|
1117
|
-
this.collectSnapshot().then((s) => {
|
|
1118
|
-
this.cachedSnapshot = s;
|
|
1119
|
-
this.consecutiveSampleErrors = 0;
|
|
1120
|
-
}).catch((err) => this.reportSampleError(err));
|
|
1121
|
-
this.samplingTimer = setInterval(() => {
|
|
1122
|
-
this.collectSnapshot().then((s) => {
|
|
1123
|
-
this.cachedSnapshot = s;
|
|
1124
|
-
this.consecutiveSampleErrors = 0;
|
|
1125
|
-
}).catch((err) => this.reportSampleError(err));
|
|
1126
|
-
}, intervalMs);
|
|
1127
|
-
if (this.samplingTimer.unref) this.samplingTimer.unref();
|
|
1128
1301
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
if (this.
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1302
|
+
/**
|
|
1303
|
+
* Append ONE sample — every process row of it — in ONE transaction.
|
|
1304
|
+
*
|
|
1305
|
+
* Never a write per row. `insertMany` exists for exactly this: at the 10 s
|
|
1306
|
+
* default the fleet produces 7.6 rows/s, and 7.6 separate commits per second
|
|
1307
|
+
* on the connection that also serves every cluster-wide configuration read
|
|
1308
|
+
* is a constant load nobody asked for.
|
|
1309
|
+
*/
|
|
1310
|
+
async append(nodeId, atMs, processes) {
|
|
1311
|
+
if (processes.length === 0) return 0;
|
|
1312
|
+
if (!await this.declare()) return 0;
|
|
1313
|
+
const records = processes.map((p) => ({ data: { ...processToRow(nodeId, atMs, p) } }));
|
|
1314
|
+
try {
|
|
1315
|
+
const { inserted } = await this.store.insertMany.mutate({
|
|
1316
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1317
|
+
records
|
|
1318
|
+
});
|
|
1319
|
+
return inserted;
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
this.logger.warn("load series sample not retained — this interval will be missing", { meta: {
|
|
1322
|
+
nodeId,
|
|
1323
|
+
atMs,
|
|
1324
|
+
rows: processes.length,
|
|
1325
|
+
error: errMsg(err)
|
|
1326
|
+
} });
|
|
1327
|
+
return 0;
|
|
1146
1328
|
}
|
|
1147
1329
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1330
|
+
/**
|
|
1331
|
+
* Read one node's cold samples, oldest-first.
|
|
1332
|
+
*
|
|
1333
|
+
* `sinceMs` is EXCLUSIVE, matching the ring, so a caller passing the newest
|
|
1334
|
+
* timestamp it holds gets only what it is missing. `limitRows` bounds the
|
|
1335
|
+
* read in ROWS (not samples) because rows are what the query costs.
|
|
1336
|
+
*/
|
|
1337
|
+
async read(nodeId, sinceMs, limitRows) {
|
|
1338
|
+
if (!await this.declare()) return [];
|
|
1339
|
+
try {
|
|
1340
|
+
const records = await this.store.query.query({
|
|
1341
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1342
|
+
filter: {
|
|
1343
|
+
where: { nodeId },
|
|
1344
|
+
whereBetween: { atMs: [sinceMs + 1, Number.MAX_SAFE_INTEGER] },
|
|
1345
|
+
orderBy: {
|
|
1346
|
+
field: "atMs",
|
|
1347
|
+
direction: "asc"
|
|
1348
|
+
},
|
|
1349
|
+
limit: limitRows
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
const rows = [];
|
|
1353
|
+
for (const record of records) {
|
|
1354
|
+
const row = recordToRow(record.data);
|
|
1355
|
+
if (row !== null) rows.push(row);
|
|
1356
|
+
}
|
|
1357
|
+
return rowsToSamples(rows);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
this.logger.warn("load series cold read failed — answering from the hot window only", { meta: {
|
|
1360
|
+
nodeId,
|
|
1361
|
+
sinceMs,
|
|
1362
|
+
error: errMsg(err)
|
|
1363
|
+
} });
|
|
1364
|
+
return [];
|
|
1365
|
+
}
|
|
1178
1366
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Enforce BOTH bounds, oldest-first, through a bounded page each.
|
|
1369
|
+
*
|
|
1370
|
+
* Rate-limited to {@link pruneIntervalMs}: a bound is not a deadline, and the
|
|
1371
|
+
* append path must not pay a sweep on every sample.
|
|
1372
|
+
*/
|
|
1373
|
+
async prune(nodeIds, retention, force = false) {
|
|
1374
|
+
const now = this.nowFn();
|
|
1375
|
+
if (!force && now - this.lastPruneAtMs < this.pruneIntervalMs) return null;
|
|
1376
|
+
this.lastPruneAtMs = now;
|
|
1377
|
+
if (!await this.declare()) return null;
|
|
1378
|
+
let deletedByAge = 0;
|
|
1379
|
+
let deletedByCap = 0;
|
|
1380
|
+
let rowsExamined = 0;
|
|
1381
|
+
const ageCutoff = now - retention.retentionHours * 36e5;
|
|
1382
|
+
for (const nodeId of nodeIds) {
|
|
1383
|
+
const outcome = await this.pruneNodeToCutoff(nodeId, ageCutoff);
|
|
1384
|
+
deletedByAge += outcome.deleted;
|
|
1385
|
+
rowsExamined += outcome.examined;
|
|
1386
|
+
}
|
|
1387
|
+
const total = await this.count();
|
|
1388
|
+
const excess = total === null ? 0 : total - retention.maxRows;
|
|
1389
|
+
if (excess > 0) {
|
|
1390
|
+
const outcome = await this.pruneOldestRows(nodeIds, excess);
|
|
1391
|
+
deletedByCap = outcome.deleted;
|
|
1392
|
+
rowsExamined += outcome.examined;
|
|
1393
|
+
this.logger.warn("load series ROW CAP bit — evicting the oldest samples", { meta: {
|
|
1394
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1395
|
+
rows: total,
|
|
1396
|
+
cap: retention.maxRows,
|
|
1397
|
+
over: excess,
|
|
1398
|
+
deleted: outcome.deleted,
|
|
1399
|
+
retentionHours: retention.retentionHours,
|
|
1400
|
+
hint: "lower the retention or the sampling cadence — the cap is the guarantee, not the intention"
|
|
1401
|
+
} });
|
|
1402
|
+
}
|
|
1182
1403
|
return {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
temperature: s.cpuTemperature ?? void 0,
|
|
1188
|
-
gpuPercent: s.gpu?.utilization,
|
|
1189
|
-
gpuMemoryPercent: s.gpu && s.gpu.memoryTotalBytes > 0 ? Math.round(s.gpu.memoryUsedBytes / s.gpu.memoryTotalBytes * 1e3) / 10 : void 0
|
|
1404
|
+
deletedByAge,
|
|
1405
|
+
deletedByCap,
|
|
1406
|
+
rowsExamined,
|
|
1407
|
+
capBit: excess > 0
|
|
1190
1408
|
};
|
|
1191
1409
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
async getProcessStats(input) {
|
|
1202
|
-
const { getPidStats } = await import("../../resource-monitor-BWmQ5i-o.mjs").then((n) => n.r);
|
|
1203
|
-
const raw = await getPidStats(input.pids);
|
|
1204
|
-
const result = [];
|
|
1205
|
-
for (const [pid, s] of raw) result.push({
|
|
1206
|
-
pid,
|
|
1207
|
-
cpu: s.cpu,
|
|
1208
|
-
memory: s.memory,
|
|
1209
|
-
privateBytes: s.privateBytes,
|
|
1210
|
-
sharedBytes: s.sharedBytes
|
|
1211
|
-
});
|
|
1212
|
-
return result;
|
|
1410
|
+
/** Total rows, or `null` when the store could not answer. */
|
|
1411
|
+
async count() {
|
|
1412
|
+
if (!await this.declare()) return null;
|
|
1413
|
+
try {
|
|
1414
|
+
return await this.store.count.query({ collection: LOAD_SERIES_COLLECTION });
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
this.logger.warn("load series count failed — the row cap is not enforced this pass", { meta: { error: errMsg(err) } });
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1213
1419
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1420
|
+
/**
|
|
1421
|
+
* Delete this node's rows older than `cutoff`, at most one page's worth.
|
|
1422
|
+
*
|
|
1423
|
+
* The page is the whole point. `deleteWhere({ atMs: [0, cutoff] })` on its
|
|
1424
|
+
* own is one statement but an UNBOUNDED one — a first pass after a retention
|
|
1425
|
+
* change would delete millions of rows inside a single stalling transaction.
|
|
1426
|
+
* So the page is read first (keys only, ordered by the index), its last
|
|
1427
|
+
* `atMs` becomes the EFFECTIVE cutoff, and the delete is bounded by it.
|
|
1428
|
+
*/
|
|
1429
|
+
async pruneNodeToCutoff(nodeId, cutoff) {
|
|
1430
|
+
try {
|
|
1431
|
+
const page = await this.store.query.query({
|
|
1432
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1433
|
+
filter: {
|
|
1434
|
+
where: { nodeId },
|
|
1435
|
+
whereBetween: { atMs: [0, cutoff] },
|
|
1436
|
+
orderBy: {
|
|
1437
|
+
field: "atMs",
|
|
1438
|
+
direction: "asc"
|
|
1439
|
+
},
|
|
1440
|
+
limit: this.prunePageRows
|
|
1441
|
+
},
|
|
1442
|
+
columns: ["atMs"]
|
|
1443
|
+
});
|
|
1444
|
+
if (page.length === 0) return {
|
|
1445
|
+
deleted: 0,
|
|
1446
|
+
examined: 0
|
|
1447
|
+
};
|
|
1448
|
+
const effectiveCutoff = Number(page.at(-1)?.data["atMs"]);
|
|
1449
|
+
if (!Number.isFinite(effectiveCutoff)) return {
|
|
1450
|
+
deleted: 0,
|
|
1451
|
+
examined: page.length
|
|
1452
|
+
};
|
|
1453
|
+
const { deleted } = await this.store.deleteWhere.mutate({
|
|
1454
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1455
|
+
filter: {
|
|
1456
|
+
where: { nodeId },
|
|
1457
|
+
whereBetween: { atMs: [0, effectiveCutoff] }
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
return {
|
|
1461
|
+
deleted,
|
|
1462
|
+
examined: page.length
|
|
1463
|
+
};
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
this.logger.warn("load series age prune failed — the table keeps growing this pass", { meta: {
|
|
1466
|
+
nodeId,
|
|
1467
|
+
cutoff,
|
|
1468
|
+
error: errMsg(err)
|
|
1469
|
+
} });
|
|
1470
|
+
return {
|
|
1471
|
+
deleted: 0,
|
|
1472
|
+
examined: 0
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1234
1475
|
}
|
|
1235
|
-
/**
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1476
|
+
/**
|
|
1477
|
+
* Drop the oldest rows across the known nodes until `excess` is covered.
|
|
1478
|
+
*
|
|
1479
|
+
* Same bounded-page technique, walked per node so the one index serves it.
|
|
1480
|
+
* The node holding the oldest rows pays first, which is also the node
|
|
1481
|
+
* producing the pressure when a runaway process count is the cause.
|
|
1482
|
+
*/
|
|
1483
|
+
async pruneOldestRows(nodeIds, excess) {
|
|
1484
|
+
let remaining = Math.min(excess, this.prunePageRows);
|
|
1485
|
+
let deleted = 0;
|
|
1486
|
+
let examined = 0;
|
|
1487
|
+
for (const nodeId of nodeIds) {
|
|
1488
|
+
if (remaining <= 0) break;
|
|
1489
|
+
try {
|
|
1490
|
+
const page = await this.store.query.query({
|
|
1491
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1492
|
+
filter: {
|
|
1493
|
+
where: { nodeId },
|
|
1494
|
+
orderBy: {
|
|
1495
|
+
field: "atMs",
|
|
1496
|
+
direction: "asc"
|
|
1497
|
+
},
|
|
1498
|
+
limit: remaining
|
|
1499
|
+
},
|
|
1500
|
+
columns: ["atMs"]
|
|
1501
|
+
});
|
|
1502
|
+
examined += page.length;
|
|
1503
|
+
if (page.length === 0) continue;
|
|
1504
|
+
const cutoff = Number(page.at(-1)?.data["atMs"]);
|
|
1505
|
+
if (!Number.isFinite(cutoff)) continue;
|
|
1506
|
+
const result = await this.store.deleteWhere.mutate({
|
|
1507
|
+
collection: LOAD_SERIES_COLLECTION,
|
|
1508
|
+
filter: {
|
|
1509
|
+
where: { nodeId },
|
|
1510
|
+
whereBetween: { atMs: [0, cutoff] }
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
deleted += result.deleted;
|
|
1514
|
+
remaining -= result.deleted;
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
this.logger.warn("load series cap prune failed — the cap is not enforced this pass", { meta: {
|
|
1517
|
+
nodeId,
|
|
1518
|
+
error: errMsg(err)
|
|
1519
|
+
} });
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1259
1522
|
return {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
usedBytes: used,
|
|
1263
|
-
availableBytes: free,
|
|
1264
|
-
swapUsedBytes,
|
|
1265
|
-
swapTotalBytes
|
|
1523
|
+
deleted,
|
|
1524
|
+
examined
|
|
1266
1525
|
};
|
|
1267
1526
|
}
|
|
1268
1527
|
};
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const output = execFileSync("vm_stat", {
|
|
1290
|
-
timeout: 2e3,
|
|
1291
|
-
encoding: "utf-8"
|
|
1292
|
-
});
|
|
1293
|
-
const pageSize = 16384;
|
|
1294
|
-
const parse = (label) => {
|
|
1295
|
-
const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
|
|
1296
|
-
return match ? parseInt(match[1], 10) * pageSize : 0;
|
|
1297
|
-
};
|
|
1298
|
-
const free = parse("Pages free");
|
|
1299
|
-
const inactive = parse("Pages inactive");
|
|
1300
|
-
const purgeable = parse("Pages purgeable");
|
|
1301
|
-
const speculative = parse("Pages speculative");
|
|
1302
|
-
const available = free + inactive + purgeable + speculative;
|
|
1303
|
-
const total = os.totalmem();
|
|
1304
|
-
const used = total - available;
|
|
1305
|
-
let swapUsedBytes = 0;
|
|
1306
|
-
let swapTotalBytes = 0;
|
|
1307
|
-
try {
|
|
1308
|
-
const swapOut = execFileSync("sysctl", ["-n", "vm.swapusage"], {
|
|
1309
|
-
timeout: 2e3,
|
|
1310
|
-
encoding: "utf-8"
|
|
1311
|
-
});
|
|
1312
|
-
const totalMatch = swapOut.match(/total\s*=\s*([\d.]+)M/);
|
|
1313
|
-
const usedMatch = swapOut.match(/used\s*=\s*([\d.]+)M/);
|
|
1314
|
-
if (totalMatch) swapTotalBytes = parseFloat(totalMatch[1]) * 1024 * 1024;
|
|
1315
|
-
if (usedMatch) swapUsedBytes = parseFloat(usedMatch[1]) * 1024 * 1024;
|
|
1316
|
-
} catch {}
|
|
1317
|
-
provider._cachedMacMem = {
|
|
1318
|
-
percent: Math.round(used / total * 1e3) / 10,
|
|
1319
|
-
totalBytes: total,
|
|
1320
|
-
usedBytes: used,
|
|
1321
|
-
availableBytes: available,
|
|
1322
|
-
swapUsedBytes,
|
|
1323
|
-
swapTotalBytes
|
|
1324
|
-
};
|
|
1325
|
-
} catch {
|
|
1326
|
-
provider._cachedMacMem = null;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
function getDiskSpaceInternal(dirPath) {
|
|
1330
|
-
return new Promise((resolve, reject) => {
|
|
1331
|
-
fs.statfs(dirPath, (err, stats) => {
|
|
1332
|
-
if (err) return reject(err);
|
|
1333
|
-
const totalBytes = stats.blocks * stats.bsize;
|
|
1334
|
-
const availableBytes = stats.bavail * stats.bsize;
|
|
1335
|
-
const usedBytes = totalBytes - stats.bfree * stats.bsize;
|
|
1336
|
-
resolve({
|
|
1337
|
-
path: dirPath,
|
|
1338
|
-
totalBytes,
|
|
1339
|
-
usedBytes,
|
|
1340
|
-
availableBytes,
|
|
1341
|
-
percent: totalBytes > 0 ? Math.round(usedBytes / totalBytes * 1e3) / 10 : 0
|
|
1342
|
-
});
|
|
1343
|
-
});
|
|
1344
|
-
});
|
|
1345
|
-
}
|
|
1346
|
-
async function takeDiskIoSnapshot() {
|
|
1347
|
-
const now = Date.now();
|
|
1348
|
-
if (IS_LINUX) try {
|
|
1349
|
-
const data = fs.readFileSync("/proc/diskstats", "utf-8");
|
|
1350
|
-
let readOps = 0, readBytes = 0, writeOps = 0, writeBytes = 0;
|
|
1351
|
-
for (const line of data.split("\n")) {
|
|
1352
|
-
const parts = line.trim().split(/\s+/);
|
|
1353
|
-
if (parts.length < 14) continue;
|
|
1354
|
-
const devName = parts[2];
|
|
1355
|
-
if (/\d+$/.test(devName) && !/^nvme\d+n\d+$/.test(devName)) continue;
|
|
1356
|
-
readOps += parseInt(parts[3], 10) || 0;
|
|
1357
|
-
readBytes += (parseInt(parts[5], 10) || 0) * 512;
|
|
1358
|
-
writeOps += parseInt(parts[7], 10) || 0;
|
|
1359
|
-
writeBytes += (parseInt(parts[9], 10) || 0) * 512;
|
|
1360
|
-
}
|
|
1361
|
-
return {
|
|
1362
|
-
readBytes,
|
|
1363
|
-
writeBytes,
|
|
1364
|
-
readOps,
|
|
1365
|
-
writeOps,
|
|
1366
|
-
timestampMs: now
|
|
1367
|
-
};
|
|
1368
|
-
} catch {}
|
|
1369
|
-
return {
|
|
1370
|
-
readBytes: 0,
|
|
1371
|
-
writeBytes: 0,
|
|
1372
|
-
readOps: 0,
|
|
1373
|
-
writeOps: 0,
|
|
1374
|
-
timestampMs: now
|
|
1528
|
+
/** Structural validation on read. A malformed row is skipped, never charted. */
|
|
1529
|
+
function recordToRow(data) {
|
|
1530
|
+
const nodeId = data["nodeId"];
|
|
1531
|
+
const classification = data["classification"];
|
|
1532
|
+
if (typeof nodeId !== "string" || typeof classification !== "string") return null;
|
|
1533
|
+
const atMs = Number(data["atMs"]);
|
|
1534
|
+
const pid = Number(data["pid"]);
|
|
1535
|
+
const cpuDeci = Number(data["cpuDeci"]);
|
|
1536
|
+
const rssMib = Number(data["rssMib"]);
|
|
1537
|
+
if (![
|
|
1538
|
+
atMs,
|
|
1539
|
+
pid,
|
|
1540
|
+
cpuDeci,
|
|
1541
|
+
rssMib
|
|
1542
|
+
].every((n) => Number.isFinite(n))) return null;
|
|
1543
|
+
const rawAddon = data["addonId"];
|
|
1544
|
+
const optional = (raw) => {
|
|
1545
|
+
if (raw === null || raw === void 0) return null;
|
|
1546
|
+
const n = Number(raw);
|
|
1547
|
+
return Number.isFinite(n) ? n : null;
|
|
1375
1548
|
};
|
|
1376
|
-
}
|
|
1377
|
-
async function takeNetworkIoSnapshot() {
|
|
1378
|
-
const now = Date.now();
|
|
1379
|
-
if (IS_LINUX) try {
|
|
1380
|
-
const data = fs.readFileSync("/proc/net/dev", "utf-8");
|
|
1381
|
-
let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
|
|
1382
|
-
for (const line of data.split("\n")) {
|
|
1383
|
-
const match = line.match(/^\s*(\w+):\s+(.+)$/);
|
|
1384
|
-
if (!match) continue;
|
|
1385
|
-
if (match[1] === "lo") continue;
|
|
1386
|
-
const cols = match[2].trim().split(/\s+/);
|
|
1387
|
-
rxBytes += parseInt(cols[0], 10) || 0;
|
|
1388
|
-
rxPackets += parseInt(cols[1], 10) || 0;
|
|
1389
|
-
rxErrors += parseInt(cols[2], 10) || 0;
|
|
1390
|
-
txBytes += parseInt(cols[8], 10) || 0;
|
|
1391
|
-
txPackets += parseInt(cols[9], 10) || 0;
|
|
1392
|
-
txErrors += parseInt(cols[10], 10) || 0;
|
|
1393
|
-
}
|
|
1394
|
-
return {
|
|
1395
|
-
rxBytes,
|
|
1396
|
-
txBytes,
|
|
1397
|
-
rxPackets,
|
|
1398
|
-
txPackets,
|
|
1399
|
-
rxErrors,
|
|
1400
|
-
txErrors,
|
|
1401
|
-
timestampMs: now
|
|
1402
|
-
};
|
|
1403
|
-
} catch {}
|
|
1404
|
-
if (IS_DARWIN) try {
|
|
1405
|
-
const stdout = await execAsync("netstat", ["-ib"]);
|
|
1406
|
-
let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
|
|
1407
|
-
for (const line of stdout.split("\n")) {
|
|
1408
|
-
const parts = line.trim().split(/\s+/);
|
|
1409
|
-
if (parts.length < 11) continue;
|
|
1410
|
-
if (parts[0] === "lo0" || parts[0] === "Name") continue;
|
|
1411
|
-
if (!parts[2]?.startsWith("Link#")) continue;
|
|
1412
|
-
rxPackets += parseInt(parts[4], 10) || 0;
|
|
1413
|
-
rxErrors += parseInt(parts[5], 10) || 0;
|
|
1414
|
-
rxBytes += parseInt(parts[6], 10) || 0;
|
|
1415
|
-
txPackets += parseInt(parts[7], 10) || 0;
|
|
1416
|
-
txErrors += parseInt(parts[8], 10) || 0;
|
|
1417
|
-
txBytes += parseInt(parts[9], 10) || 0;
|
|
1418
|
-
}
|
|
1419
|
-
return {
|
|
1420
|
-
rxBytes,
|
|
1421
|
-
txBytes,
|
|
1422
|
-
rxPackets,
|
|
1423
|
-
txPackets,
|
|
1424
|
-
rxErrors,
|
|
1425
|
-
txErrors,
|
|
1426
|
-
timestampMs: now
|
|
1427
|
-
};
|
|
1428
|
-
} catch {}
|
|
1429
1549
|
return {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1550
|
+
nodeId,
|
|
1551
|
+
atMs,
|
|
1552
|
+
pid,
|
|
1553
|
+
addonId: typeof rawAddon === "string" ? rawAddon : null,
|
|
1554
|
+
classification,
|
|
1555
|
+
cpuDeci,
|
|
1556
|
+
rssMib,
|
|
1557
|
+
cpuMainDeci: optional(data["cpuMainDeci"]),
|
|
1558
|
+
cpuGcDeci: optional(data["cpuGcDeci"])
|
|
1437
1559
|
};
|
|
1438
1560
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
try {
|
|
1498
|
-
openFds = fs.readdirSync(`/proc/${process.pid}/fd`).length;
|
|
1499
|
-
} catch {}
|
|
1500
|
-
try {
|
|
1501
|
-
const match = fs.readFileSync(`/proc/${process.pid}/status`, "utf-8").match(/Threads:\s+(\d+)/);
|
|
1502
|
-
if (match) threadCount = parseInt(match[1], 10);
|
|
1503
|
-
} catch {}
|
|
1561
|
+
//#endregion
|
|
1562
|
+
//#region src/builtins/native-metrics/load-series-config.ts
|
|
1563
|
+
/**
|
|
1564
|
+
* The two knobs of the durable load series, and the arithmetic that makes them
|
|
1565
|
+
* honest.
|
|
1566
|
+
*
|
|
1567
|
+
* ## Where they live, and why there
|
|
1568
|
+
*
|
|
1569
|
+
* In `native-metrics`'s OWN global settings document — the one that already
|
|
1570
|
+
* exists and already carries `samplingIntervalMs`. Fields on a document, never
|
|
1571
|
+
* a method per knob: `system.getLoggingSettings` / `setLoggingSettings` set
|
|
1572
|
+
* that precedent and it holds here.
|
|
1573
|
+
*
|
|
1574
|
+
* They are NOT in the logging document, and that is a decision rather than an
|
|
1575
|
+
* omission. That document is about levels and diagnostic windows; a metrics
|
|
1576
|
+
* cadence and a table's retention are neither. Two documents that both claim a
|
|
1577
|
+
* knob is how this repo has already shipped a switch nobody read.
|
|
1578
|
+
*
|
|
1579
|
+
* They are cluster-wide, not per-node. Every node emits on the same cadence or
|
|
1580
|
+
* the fleet's series cannot be laid over each other, and the table is single
|
|
1581
|
+
* and hub-resident, so a per-node retention would be a promise nothing could
|
|
1582
|
+
* keep.
|
|
1583
|
+
*
|
|
1584
|
+
* ## Refused, not clamped
|
|
1585
|
+
*
|
|
1586
|
+
* A cadence outside 5-60 s is REFUSED. Clamping would silently store 10 when
|
|
1587
|
+
* the operator typed 2 and then report 10 back as though it had been accepted
|
|
1588
|
+
* — the failure mode where a knob and its readback agree on a value nobody
|
|
1589
|
+
* chose. The schema advertises the bounds; this refuses anything else.
|
|
1590
|
+
*/
|
|
1591
|
+
/** Why a value was refused — the message an operator is shown verbatim. */
|
|
1592
|
+
var LoadSeriesConfigError = class extends Error {
|
|
1593
|
+
constructor(message) {
|
|
1594
|
+
super(message);
|
|
1595
|
+
this.name = "LoadSeriesConfigError";
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
function requireInteger(value, field) {
|
|
1599
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
1600
|
+
if (!Number.isFinite(n)) throw new LoadSeriesConfigError(`${field} must be a number, received ${String(value)}`);
|
|
1601
|
+
return Math.round(n);
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Resolve the configuration, REFUSING anything out of range.
|
|
1605
|
+
*
|
|
1606
|
+
* An ABSENT field takes its default — that is not a refusal, it is a document
|
|
1607
|
+
* that has never been written. A PRESENT field outside its bounds throws.
|
|
1608
|
+
*/
|
|
1609
|
+
function resolveLoadSeriesConfig(raw) {
|
|
1610
|
+
let cadenceSec = 10;
|
|
1611
|
+
if (raw.loadSeriesCadenceSec !== void 0 && raw.loadSeriesCadenceSec !== null) {
|
|
1612
|
+
cadenceSec = requireInteger(raw.loadSeriesCadenceSec, "loadSeriesCadenceSec");
|
|
1613
|
+
if (cadenceSec < 5 || cadenceSec > 60) throw new LoadSeriesConfigError(`load series cadence must be between 5 and 60 seconds — refused ${cadenceSec}`);
|
|
1614
|
+
}
|
|
1615
|
+
let retentionHours = 6;
|
|
1616
|
+
if (raw.loadSeriesRetentionHours !== void 0 && raw.loadSeriesRetentionHours !== null) {
|
|
1617
|
+
retentionHours = requireInteger(raw.loadSeriesRetentionHours, "loadSeriesRetentionHours");
|
|
1618
|
+
if (retentionHours < 1 || retentionHours > 72) throw new LoadSeriesConfigError(`load series retention must be between 1 and 72 hours — refused ${retentionHours}`);
|
|
1504
1619
|
}
|
|
1505
|
-
const nodeProcess = process;
|
|
1506
1620
|
return {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
activeRequests: (nodeProcess._getActiveRequests?.() ?? []).length
|
|
1621
|
+
cadenceSec,
|
|
1622
|
+
retentionHours,
|
|
1623
|
+
maxRows: DEFAULT_MAX_ROWS
|
|
1511
1624
|
};
|
|
1512
1625
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
} catch {
|
|
1533
|
-
return null;
|
|
1534
|
-
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Project the cost of a configuration from the numbers the cluster is actually
|
|
1628
|
+
* reporting — nodes and processes OBSERVED, never a constant.
|
|
1629
|
+
*
|
|
1630
|
+
* This is what makes the knob honest: an operator raising retention to three
|
|
1631
|
+
* days sees the row count and the megabytes BEFORE applying it, rather than
|
|
1632
|
+
* discovering them afterwards. `MEASURED_BYTES_PER_ROW` is measured, not
|
|
1633
|
+
* guessed — see `load-series-store.ts`.
|
|
1634
|
+
*/
|
|
1635
|
+
function projectLoadSeriesCost(input) {
|
|
1636
|
+
const samplesPerNodeWindow = input.config.retentionHours * 3600 / input.config.cadenceSec;
|
|
1637
|
+
const intendedRows = Math.round(samplesPerNodeWindow * Math.max(input.observedProcessRows, 0));
|
|
1638
|
+
const boundedRows = Math.min(intendedRows, input.config.maxRows);
|
|
1639
|
+
return {
|
|
1640
|
+
intendedRows,
|
|
1641
|
+
boundedRows,
|
|
1642
|
+
estimatedBytes: boundedRows * 87,
|
|
1643
|
+
capBites: intendedRows > input.config.maxRows
|
|
1644
|
+
};
|
|
1535
1645
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
})
|
|
1646
|
+
/** Human-readable one-liner for the settings form. */
|
|
1647
|
+
function describeLoadSeriesCost(projection) {
|
|
1648
|
+
const mib = (projection.estimatedBytes / 1048576).toFixed(1);
|
|
1649
|
+
const rows = projection.intendedRows.toLocaleString("en-US");
|
|
1650
|
+
const capped = projection.boundedRows.toLocaleString("en-US");
|
|
1651
|
+
if (!projection.capBites) return `This configuration retains ~${rows} rows ≈ ${mib} MiB on the hub database (NVMe cache), measured at 87 bytes per row.`;
|
|
1652
|
+
return `This configuration WANTS ~${rows} rows, above the ${projection.boundedRows.toLocaleString("en-US")}-row hard cap. The table will hold ~${capped} rows ≈ ${mib} MiB and the OLDEST samples will be evicted, so the effective window is shorter than the retention you asked for.`;
|
|
1543
1653
|
}
|
|
1544
1654
|
//#endregion
|
|
1545
|
-
//#region src/builtins/native-metrics/load-series-
|
|
1546
|
-
/**
|
|
1547
|
-
* @durable class=audit owner=metrics-native
|
|
1548
|
-
* write="one row per process per sample, appended in ONE transaction per
|
|
1549
|
-
* sample (`settingsStore.insertMany`) by the HUB's `native-metrics`, off its
|
|
1550
|
-
* subscription to `metrics.node-processes-snapshot`. Emission is
|
|
1551
|
-
* fixed-cadence, so the write rate is deterministic: nodes x processes /
|
|
1552
|
-
* cadence, 7.6 rows/s on the live fleet at the 10 s default. Only a sample
|
|
1553
|
-
* the in-memory ring ACCEPTED is written, which is what makes the append
|
|
1554
|
-
* idempotent without a unique key."
|
|
1555
|
-
* retention="TWO bounds, swept together at most once a minute. By AGE, the
|
|
1556
|
-
* operator's `loadSeriesRetentionHours` (default 6, the intention); by ROWS,
|
|
1557
|
-
* DEFAULT_MAX_ROWS (the guarantee, which bites regardless of the age knob).
|
|
1558
|
-
* Both prune oldest-first through a bounded page — never a full scan — and
|
|
1559
|
-
* the row cap logs when it bites. Losing the table loses the cold window
|
|
1560
|
-
* only: the ring keeps serving the last hour and the charts keep drawing."
|
|
1561
|
-
*/
|
|
1562
|
-
var LOAD_SERIES_COLLECTION = "metrics:node-load-samples";
|
|
1655
|
+
//#region src/builtins/native-metrics/load-series-reader.ts
|
|
1563
1656
|
/**
|
|
1564
|
-
*
|
|
1657
|
+
* The ONE reader over both retention tiers.
|
|
1565
1658
|
*
|
|
1566
|
-
* The
|
|
1567
|
-
*
|
|
1659
|
+
* The hot window is the in-memory ring; the cold one is
|
|
1660
|
+
* `metrics:node-load-samples`. A caller never learns which tier a point came
|
|
1661
|
+
* from, and that is a hard requirement rather than a nicety: a second reader
|
|
1662
|
+
* is how two charts start disagreeing about the same node, and it is how "the
|
|
1663
|
+
* ring says X, the table says Y" becomes a question nobody can answer.
|
|
1568
1664
|
*
|
|
1569
|
-
*
|
|
1570
|
-
* | --- | --- | --- |
|
|
1571
|
-
* | 10 s | 6 h (default) | 164 160 |
|
|
1572
|
-
* | 5 s | 6 h | 328 320 |
|
|
1573
|
-
* | 5 s | 72 h | 3 939 840 — capped here |
|
|
1665
|
+
* Three things happen here, in this order, and each one is load-bearing:
|
|
1574
1666
|
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
1667
|
+
* 1. **Merge and dedupe on `atMs`.** The two tiers OVERLAP by construction —
|
|
1668
|
+
* the ring holds the last 180 snapshots and the table holds every snapshot
|
|
1669
|
+
* the ring accepted, including those same 180. A snapshot present in both
|
|
1670
|
+
* must contribute exactly once. Identity is `atMs`, because one node emits
|
|
1671
|
+
* one snapshot per tick and a timestamp already held IS that snapshot. The
|
|
1672
|
+
* HOT copy wins on a tie: it is the payload as it arrived, while the cold
|
|
1673
|
+
* one has been through the storage encode.
|
|
1674
|
+
* 2. **Fold per function.** The chart draws one series per addon, so the fold
|
|
1675
|
+
* happens here rather than in the browser.
|
|
1676
|
+
* 3. **Reduce, preserving the extremes.** See `load-series-fold.ts` for why
|
|
1677
|
+
* the representative of a bucket is its MAX and never its mean.
|
|
1678
|
+
*
|
|
1679
|
+
* A GAP stays a gap through all three. Emission is fixed-cadence, so a missing
|
|
1680
|
+
* interval means one thing — nobody reported — and nothing here fills it in.
|
|
1583
1681
|
*/
|
|
1584
|
-
var DEFAULT_MAX_ROWS = 5e5;
|
|
1585
|
-
var LOAD_SERIES_COLUMNS = [
|
|
1586
|
-
(
|
|
1587
|
-
/** `INTEGER PRIMARY KEY` = SQLite rowid alias: the key IS the row's address,
|
|
1588
|
-
* so it costs no separate index and no stored string. */
|
|
1589
|
-
{
|
|
1590
|
-
name: "id",
|
|
1591
|
-
type: "INTEGER",
|
|
1592
|
-
primaryKey: true,
|
|
1593
|
-
notNull: true
|
|
1594
|
-
}),
|
|
1595
|
-
{
|
|
1596
|
-
name: "nodeId",
|
|
1597
|
-
type: "TEXT",
|
|
1598
|
-
notNull: true
|
|
1599
|
-
},
|
|
1600
|
-
(
|
|
1601
|
-
/** The EMITTING node's timestamp for the sample this row belongs to. Every
|
|
1602
|
-
* row of one sample shares it — that is what makes a sample reassemblable. */
|
|
1603
|
-
{
|
|
1604
|
-
name: "atMs",
|
|
1605
|
-
type: "INTEGER",
|
|
1606
|
-
notNull: true
|
|
1607
|
-
}),
|
|
1608
|
-
{
|
|
1609
|
-
name: "pid",
|
|
1610
|
-
type: "INTEGER",
|
|
1611
|
-
notNull: true
|
|
1612
|
-
},
|
|
1613
|
-
(
|
|
1614
|
-
/** `NULL` for a process no addon owns — `root` and `system` both. */
|
|
1615
|
-
{
|
|
1616
|
-
name: "addonId",
|
|
1617
|
-
type: "TEXT"
|
|
1618
|
-
}),
|
|
1619
|
-
{
|
|
1620
|
-
name: "classification",
|
|
1621
|
-
type: "TEXT",
|
|
1622
|
-
notNull: true
|
|
1623
|
-
},
|
|
1624
|
-
(
|
|
1625
|
-
/** `ps pcpu` x 10. A LIFETIME average, not a rate — see `NodeProcess`. */
|
|
1626
|
-
{
|
|
1627
|
-
name: "cpuDeci",
|
|
1628
|
-
type: "INTEGER",
|
|
1629
|
-
notNull: true
|
|
1630
|
-
}),
|
|
1631
|
-
{
|
|
1632
|
-
name: "rssMib",
|
|
1633
|
-
type: "INTEGER",
|
|
1634
|
-
notNull: true
|
|
1635
|
-
},
|
|
1636
|
-
(
|
|
1637
|
-
/** Instantaneous main-thread CPU% x 10. `NULL` = UNKNOWN, never zero. */
|
|
1638
|
-
{
|
|
1639
|
-
name: "cpuMainDeci",
|
|
1640
|
-
type: "INTEGER"
|
|
1641
|
-
}),
|
|
1642
|
-
(
|
|
1643
|
-
/** Instantaneous V8-helper-pool CPU% x 10. `NULL` = UNKNOWN, never zero. */
|
|
1644
|
-
{
|
|
1645
|
-
name: "cpuGcDeci",
|
|
1646
|
-
type: "INTEGER"
|
|
1647
|
-
})
|
|
1648
|
-
];
|
|
1649
|
-
var LOAD_SERIES_INDEXES = [(
|
|
1650
1682
|
/**
|
|
1651
|
-
*
|
|
1683
|
+
* Merge the two tiers into one deduped, oldest-first list of samples.
|
|
1652
1684
|
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
* the other end. A second index on `atMs` alone was measured and rejected:
|
|
1657
|
-
* it cost 23 B/row (196.0 → 172.8 with the two column drops) to serve a
|
|
1658
|
-
* cross-node prune that a per-node loop over three nodes already serves.
|
|
1685
|
+
* Exported because the dedup is the property worth testing on its own: a
|
|
1686
|
+
* doubled point is invisible in a total and obvious only as a chart that reads
|
|
1687
|
+
* twice as loud as the machine.
|
|
1659
1688
|
*/
|
|
1660
|
-
{
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
/** Tenths → percent. `null` stays `null`: unknown is not zero. */
|
|
1666
|
-
function fromDeci(value) {
|
|
1667
|
-
return value === null ? null : Math.round(value) / 10;
|
|
1689
|
+
function mergeSamples(cold, hot) {
|
|
1690
|
+
const byAt = /* @__PURE__ */ new Map();
|
|
1691
|
+
for (const sample of cold) byAt.set(sample.atMs, sample);
|
|
1692
|
+
for (const sample of hot) byAt.set(sample.atMs, sample);
|
|
1693
|
+
return [...byAt.values()].toSorted((a, b) => a.atMs - b.atMs);
|
|
1668
1694
|
}
|
|
1669
|
-
/**
|
|
1670
|
-
function
|
|
1671
|
-
return
|
|
1695
|
+
/** `RetainedProcess` is already the fold's row shape — named, not re-derived. */
|
|
1696
|
+
function toFoldRows(processes) {
|
|
1697
|
+
return processes;
|
|
1672
1698
|
}
|
|
1673
|
-
/**
|
|
1674
|
-
function
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1699
|
+
/**
|
|
1700
|
+
* Merge, fold and reduce. The whole read path in one pure function, so every
|
|
1701
|
+
* property that matters — dedup, peak survival, an empty bucket staying empty
|
|
1702
|
+
* — is testable without a database or a ring.
|
|
1703
|
+
*/
|
|
1704
|
+
function mergeLoadSeries(input) {
|
|
1705
|
+
const samples = mergeSamples(input.cold, input.hot);
|
|
1706
|
+
if (samples.length === 0) return {
|
|
1707
|
+
series: [],
|
|
1708
|
+
bucketMs: input.cadenceMs,
|
|
1709
|
+
retainedSamples: 0,
|
|
1710
|
+
oldestAtMs: null
|
|
1711
|
+
};
|
|
1712
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1713
|
+
for (const sample of samples) for (const bucket of foldSnapshotByFunction(toFoldRows(sample.processes), sample.atMs)) {
|
|
1714
|
+
const held = byKey.get(bucket.key);
|
|
1715
|
+
if (held === void 0) byKey.set(bucket.key, {
|
|
1716
|
+
kind: bucket.kind,
|
|
1717
|
+
points: [bucket.point]
|
|
1718
|
+
});
|
|
1719
|
+
else held.points.push(bucket.point);
|
|
1720
|
+
}
|
|
1721
|
+
const oldestAtMs = samples[0]?.atMs ?? null;
|
|
1722
|
+
const newestAtMs = samples.at(-1)?.atMs ?? oldestAtMs;
|
|
1723
|
+
const spanMs = oldestAtMs === null || newestAtMs === null ? 0 : newestAtMs - oldestAtMs + input.cadenceMs;
|
|
1724
|
+
const bucketMs = input.maxPoints === void 0 ? input.cadenceMs : resolveBucketMs(spanMs, input.cadenceMs, input.maxPoints);
|
|
1725
|
+
const origin = oldestAtMs ?? 0;
|
|
1726
|
+
const series = [...byKey.entries()].map(([key, held]) => ({
|
|
1727
|
+
key,
|
|
1728
|
+
kind: held.kind,
|
|
1729
|
+
points: bucketMs > input.cadenceMs ? reducePoints(held.points, bucketMs, origin) : held.points
|
|
1730
|
+
}));
|
|
1731
|
+
const weight = (s) => {
|
|
1732
|
+
const last = s.points.at(-1);
|
|
1733
|
+
if (last === void 0) return -1;
|
|
1734
|
+
if (last.cpuMainPercent !== null && last.cpuGcPercent !== null) return last.cpuMainPercent + last.cpuGcPercent;
|
|
1735
|
+
return last.cpuLifetimePercent;
|
|
1683
1736
|
};
|
|
1684
|
-
}
|
|
1685
|
-
/** The shape a reader gets → the storage row. The ONE encode. */
|
|
1686
|
-
function processToRow(nodeId, atMs, process) {
|
|
1687
1737
|
return {
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
cpuMainDeci: toDeci(process.cpuMainPercent),
|
|
1696
|
-
cpuGcDeci: toDeci(process.cpuGcPercent)
|
|
1738
|
+
series: series.toSorted((a, b) => {
|
|
1739
|
+
const d = weight(b) - weight(a);
|
|
1740
|
+
return d !== 0 ? d : a.key.localeCompare(b.key);
|
|
1741
|
+
}),
|
|
1742
|
+
bucketMs,
|
|
1743
|
+
retainedSamples: samples.length,
|
|
1744
|
+
oldestAtMs
|
|
1697
1745
|
};
|
|
1698
1746
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1747
|
+
//#endregion
|
|
1748
|
+
//#region src/builtins/native-metrics/gpu-probe.ts
|
|
1749
|
+
/**
|
|
1750
|
+
* The Linux GPU probe, forking only when there is something to fork.
|
|
1751
|
+
*
|
|
1752
|
+
* `nvidia-smi` was spawned on every sample — twelve times a minute from
|
|
1753
|
+
* hub-main — on a host with an Intel i915 and no NVIDIA driver. Each spawn
|
|
1754
|
+
* was a synchronous `fork()` of the largest process on the host (D441: p50
|
|
1755
|
+
* 91 ms at 5.8 GB RSS), and each one failed at `exec` and yielded `null`.
|
|
1756
|
+
* Twelve forks a minute bought nothing (D452).
|
|
1757
|
+
*
|
|
1758
|
+
* Whether the binary exists is a question `access(2)` answers on the libuv
|
|
1759
|
+
* pool without a fork. It is asked on EVERY call rather than latched: a handful
|
|
1760
|
+
* of `stat`s every five seconds costs nothing, and a latch is a second fact
|
|
1761
|
+
* about the host that can go stale — the same shape as the caches D224
|
|
1762
|
+
* forbids.
|
|
1763
|
+
*/
|
|
1764
|
+
var nodeAccessCheck = (path) => access(path, constants.X_OK);
|
|
1765
|
+
/**
|
|
1766
|
+
* The first `PATH` entry under which `binary` is executable, or `null`.
|
|
1767
|
+
* An empty or missing `PATH` asks nothing.
|
|
1768
|
+
*/
|
|
1769
|
+
async function findOnPath(binary, pathEnv, check = nodeAccessCheck) {
|
|
1770
|
+
const dirs = (pathEnv ?? "").split(":").filter((dir) => dir.length > 0);
|
|
1771
|
+
for (const dir of dirs) {
|
|
1772
|
+
const candidate = `${dir}/${binary}`;
|
|
1773
|
+
try {
|
|
1774
|
+
await check(candidate);
|
|
1775
|
+
return candidate;
|
|
1776
|
+
} catch {}
|
|
1706
1777
|
}
|
|
1707
|
-
return
|
|
1708
|
-
atMs,
|
|
1709
|
-
processes
|
|
1710
|
-
}));
|
|
1778
|
+
return null;
|
|
1711
1779
|
}
|
|
1780
|
+
var NVIDIA_SMI_ARGS = ["--query-gpu=utilization.gpu,name,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits"];
|
|
1712
1781
|
/**
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
* Every method is best-effort and says so in the log rather than throwing at
|
|
1717
|
-
* the emitter: a storage hiccup must cost a gap in a chart, never a dropped
|
|
1718
|
-
* snapshot on the bus.
|
|
1782
|
+
* `nvidia-smi`'s one-line CSV as a `MetricsGpuInfo`, or `null` when the
|
|
1783
|
+
* binary is absent, fails, or prints something else. Never throws.
|
|
1719
1784
|
*/
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1785
|
+
async function readNvidiaGpu(deps) {
|
|
1786
|
+
const binary = await deps.findOnPath("nvidia-smi");
|
|
1787
|
+
if (binary === null) return null;
|
|
1788
|
+
let stdout;
|
|
1789
|
+
try {
|
|
1790
|
+
stdout = await deps.exec(binary, NVIDIA_SMI_ARGS);
|
|
1791
|
+
} catch {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
const parts = stdout.trim().split(",").map((s) => s.trim());
|
|
1795
|
+
if (parts.length < 5) return null;
|
|
1796
|
+
return {
|
|
1797
|
+
utilization: parseInt(parts[0], 10) || 0,
|
|
1798
|
+
model: parts[1] ?? "Unknown GPU",
|
|
1799
|
+
memoryUsedBytes: (parseInt(parts[2], 10) || 0) * 1024 * 1024,
|
|
1800
|
+
memoryTotalBytes: (parseInt(parts[3], 10) || 0) * 1024 * 1024,
|
|
1801
|
+
temperature: parseInt(parts[4], 10) || null
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
//#endregion
|
|
1805
|
+
//#region src/builtins/native-metrics/native-metrics-provider.ts
|
|
1806
|
+
var IS_DARWIN = os.platform() === "darwin";
|
|
1807
|
+
var IS_LINUX = os.platform() === "linux";
|
|
1808
|
+
var NativeMetricsProvider = class {
|
|
1809
|
+
id = "native";
|
|
1810
|
+
cachedSnapshot = null;
|
|
1811
|
+
samplingTimer = null;
|
|
1812
|
+
macMemTimer = null;
|
|
1813
|
+
prevCpu;
|
|
1814
|
+
diagnostics = null;
|
|
1815
|
+
consecutiveSampleErrors = 0;
|
|
1816
|
+
constructor() {
|
|
1817
|
+
this.prevCpu = aggregateCpuTimes();
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Optional diagnostics sink for sampling errors. The first failure and
|
|
1821
|
+
* every Nth failure thereafter are reported so a permanently-broken
|
|
1822
|
+
* collector doesn't spam logs but is still observable.
|
|
1823
|
+
*/
|
|
1824
|
+
setDiagnostics(diag) {
|
|
1825
|
+
this.diagnostics = diag;
|
|
1826
|
+
}
|
|
1827
|
+
startSampling(intervalMs) {
|
|
1828
|
+
if (IS_DARWIN) {
|
|
1829
|
+
sampleMacMemory(this);
|
|
1830
|
+
this.macMemTimer = setInterval(() => sampleMacMemory(this), 5e3);
|
|
1831
|
+
if (this.macMemTimer.unref) this.macMemTimer.unref();
|
|
1832
|
+
}
|
|
1833
|
+
this.collectSnapshot().then((s) => {
|
|
1834
|
+
this.cachedSnapshot = s;
|
|
1835
|
+
this.consecutiveSampleErrors = 0;
|
|
1836
|
+
}).catch((err) => this.reportSampleError(err));
|
|
1837
|
+
this.samplingTimer = setInterval(() => {
|
|
1838
|
+
this.collectSnapshot().then((s) => {
|
|
1839
|
+
this.cachedSnapshot = s;
|
|
1840
|
+
this.consecutiveSampleErrors = 0;
|
|
1841
|
+
}).catch((err) => this.reportSampleError(err));
|
|
1842
|
+
}, intervalMs);
|
|
1843
|
+
if (this.samplingTimer.unref) this.samplingTimer.unref();
|
|
1844
|
+
}
|
|
1845
|
+
reportSampleError(err) {
|
|
1846
|
+
this.consecutiveSampleErrors++;
|
|
1847
|
+
if (!this.diagnostics) return;
|
|
1848
|
+
if (!(this.consecutiveSampleErrors === 1 || this.consecutiveSampleErrors % 60 === 0)) return;
|
|
1849
|
+
this.diagnostics.warn("metrics-provider sample failed", {
|
|
1850
|
+
consecutiveErrors: this.consecutiveSampleErrors,
|
|
1851
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
stopSampling() {
|
|
1855
|
+
if (this.samplingTimer) {
|
|
1856
|
+
clearInterval(this.samplingTimer);
|
|
1857
|
+
this.samplingTimer = null;
|
|
1858
|
+
}
|
|
1859
|
+
if (this.macMemTimer) {
|
|
1860
|
+
clearInterval(this.macMemTimer);
|
|
1861
|
+
this.macMemTimer = null;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
async collectSnapshot() {
|
|
1865
|
+
const cpu = this.sampleCpu();
|
|
1866
|
+
const memory = this.getMemoryInfo();
|
|
1867
|
+
const processInfo = getProcessResources();
|
|
1868
|
+
const [gpu, network, disk, cpuTemp] = await Promise.all([
|
|
1869
|
+
getGpuInfoInternal().catch(() => null),
|
|
1870
|
+
takeNetworkIoSnapshot(),
|
|
1871
|
+
takeDiskIoSnapshot(),
|
|
1872
|
+
getCpuTemperatureInternal().catch(() => null)
|
|
1873
|
+
]);
|
|
1874
|
+
const snapshot = {
|
|
1875
|
+
cpu,
|
|
1876
|
+
memory,
|
|
1877
|
+
gpu,
|
|
1878
|
+
network,
|
|
1879
|
+
disk,
|
|
1880
|
+
pressure: {
|
|
1881
|
+
cpu: getPressure("cpu"),
|
|
1882
|
+
memory: getPressure("memory"),
|
|
1883
|
+
io: getPressure("io")
|
|
1884
|
+
},
|
|
1885
|
+
process: processInfo,
|
|
1886
|
+
cpuTemperature: cpuTemp,
|
|
1887
|
+
timestampMs: Date.now()
|
|
1888
|
+
};
|
|
1889
|
+
this.cachedSnapshot = snapshot;
|
|
1890
|
+
return snapshot;
|
|
1891
|
+
}
|
|
1892
|
+
async getCached() {
|
|
1893
|
+
return this.cachedSnapshot;
|
|
1894
|
+
}
|
|
1895
|
+
async getCurrent() {
|
|
1896
|
+
const s = this.cachedSnapshot ?? await this.collectSnapshot();
|
|
1897
|
+
const mb = (bytes) => Math.round(bytes / 1024 / 1024);
|
|
1898
|
+
return {
|
|
1899
|
+
cpuPercent: s.cpu.total,
|
|
1900
|
+
memoryPercent: s.memory.percent,
|
|
1901
|
+
memoryUsedMB: mb(s.memory.usedBytes),
|
|
1902
|
+
memoryTotalMB: mb(s.memory.totalBytes),
|
|
1903
|
+
temperature: s.cpuTemperature ?? void 0,
|
|
1904
|
+
gpuPercent: s.gpu?.utilization,
|
|
1905
|
+
gpuMemoryPercent: s.gpu && s.gpu.memoryTotalBytes > 0 ? Math.round(s.gpu.memoryUsedBytes / s.gpu.memoryTotalBytes * 1e3) / 10 : void 0
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
async getDiskSpace(input) {
|
|
1909
|
+
return getDiskSpaceInternal(input.dirPath);
|
|
1910
|
+
}
|
|
1911
|
+
async getGpuInfo() {
|
|
1912
|
+
return getGpuInfoInternal();
|
|
1913
|
+
}
|
|
1914
|
+
async getCpuTemperature() {
|
|
1915
|
+
return getCpuTemperatureInternal();
|
|
1916
|
+
}
|
|
1917
|
+
async getProcessStats(input) {
|
|
1918
|
+
const { getPidStats } = await import("../../resource-monitor-Vp0bPV3U.mjs").then((n) => n.r);
|
|
1919
|
+
const raw = await getPidStats(input.pids);
|
|
1920
|
+
const result = [];
|
|
1921
|
+
for (const [pid, s] of raw) result.push({
|
|
1922
|
+
pid,
|
|
1923
|
+
cpu: s.cpu,
|
|
1924
|
+
memory: s.memory,
|
|
1925
|
+
privateBytes: s.privateBytes,
|
|
1926
|
+
sharedBytes: s.sharedBytes
|
|
1927
|
+
});
|
|
1928
|
+
return result;
|
|
1929
|
+
}
|
|
1930
|
+
sampleCpu() {
|
|
1931
|
+
const curr = aggregateCpuTimes();
|
|
1932
|
+
const prev = this.prevCpu;
|
|
1933
|
+
this.prevCpu = curr;
|
|
1934
|
+
const dUser = curr.user - prev.user;
|
|
1935
|
+
const dNice = curr.nice - prev.nice;
|
|
1936
|
+
const dSys = curr.sys - prev.sys;
|
|
1937
|
+
const dIrq = curr.irq - prev.irq;
|
|
1938
|
+
const dIdle = curr.idle - prev.idle;
|
|
1939
|
+
const dTotal = dUser + dNice + dSys + dIrq + dIdle;
|
|
1940
|
+
const pct = (v) => dTotal === 0 ? 0 : Math.round(v / dTotal * 1e3) / 10;
|
|
1941
|
+
return {
|
|
1942
|
+
total: pct(dUser + dNice + dSys + dIrq),
|
|
1943
|
+
user: pct(dUser),
|
|
1944
|
+
system: pct(dSys),
|
|
1945
|
+
irq: pct(dIrq),
|
|
1946
|
+
nice: pct(dNice),
|
|
1947
|
+
loadAvg: os.loadavg(),
|
|
1948
|
+
cores: os.cpus().length
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
/** Cached macOS memory (set by sampleMacMemory timer). */
|
|
1952
|
+
_cachedMacMem = null;
|
|
1953
|
+
getMemoryInfo() {
|
|
1954
|
+
const total = os.totalmem();
|
|
1955
|
+
if (total === 0) return {
|
|
1956
|
+
percent: 0,
|
|
1957
|
+
totalBytes: 0,
|
|
1958
|
+
usedBytes: 0,
|
|
1959
|
+
availableBytes: 0,
|
|
1960
|
+
swapUsedBytes: 0,
|
|
1961
|
+
swapTotalBytes: 0
|
|
1962
|
+
};
|
|
1963
|
+
if (IS_DARWIN && this._cachedMacMem !== null) return this._cachedMacMem;
|
|
1964
|
+
const free = os.freemem();
|
|
1965
|
+
const used = total - free;
|
|
1966
|
+
let swapUsedBytes = 0;
|
|
1967
|
+
let swapTotalBytes = 0;
|
|
1968
|
+
if (IS_LINUX) try {
|
|
1969
|
+
const meminfo = fs.readFileSync("/proc/meminfo", "utf-8");
|
|
1970
|
+
const swapTotal = parseInt(meminfo.match(/SwapTotal:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
|
|
1971
|
+
const swapFree = parseInt(meminfo.match(/SwapFree:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
|
|
1972
|
+
swapTotalBytes = swapTotal;
|
|
1973
|
+
swapUsedBytes = swapTotal - swapFree;
|
|
1974
|
+
} catch {}
|
|
1975
|
+
return {
|
|
1976
|
+
percent: Math.round((total - free) / total * 1e3) / 10,
|
|
1977
|
+
totalBytes: total,
|
|
1978
|
+
usedBytes: used,
|
|
1979
|
+
availableBytes: free,
|
|
1980
|
+
swapUsedBytes,
|
|
1981
|
+
swapTotalBytes
|
|
1982
|
+
};
|
|
1734
1983
|
}
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
}))
|
|
1746
|
-
});
|
|
1747
|
-
this.declared = true;
|
|
1748
|
-
return true;
|
|
1749
|
-
} catch (err) {
|
|
1750
|
-
this.logger.warn("load series declareCollection failed — nothing will be retained on disk", { meta: {
|
|
1751
|
-
collection: LOAD_SERIES_COLLECTION,
|
|
1752
|
-
error: errMsg(err)
|
|
1753
|
-
} });
|
|
1754
|
-
return false;
|
|
1755
|
-
}
|
|
1984
|
+
};
|
|
1985
|
+
function aggregateCpuTimes() {
|
|
1986
|
+
const cpus = os.cpus();
|
|
1987
|
+
let user = 0, nice = 0, sys = 0, irq = 0, idle = 0;
|
|
1988
|
+
for (const cpu of cpus) {
|
|
1989
|
+
user += cpu.times.user;
|
|
1990
|
+
nice += cpu.times.nice;
|
|
1991
|
+
sys += cpu.times.sys;
|
|
1992
|
+
irq += cpu.times.irq;
|
|
1993
|
+
idle += cpu.times.idle;
|
|
1756
1994
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1995
|
+
return {
|
|
1996
|
+
user,
|
|
1997
|
+
nice,
|
|
1998
|
+
sys,
|
|
1999
|
+
irq,
|
|
2000
|
+
idle
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
function sampleMacMemory(provider) {
|
|
2004
|
+
try {
|
|
2005
|
+
const output = execFileSync("vm_stat", {
|
|
2006
|
+
timeout: 2e3,
|
|
2007
|
+
encoding: "utf-8"
|
|
2008
|
+
});
|
|
2009
|
+
const pageSize = 16384;
|
|
2010
|
+
const parse = (label) => {
|
|
2011
|
+
const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
|
|
2012
|
+
return match ? parseInt(match[1], 10) * pageSize : 0;
|
|
2013
|
+
};
|
|
2014
|
+
const free = parse("Pages free");
|
|
2015
|
+
const inactive = parse("Pages inactive");
|
|
2016
|
+
const purgeable = parse("Pages purgeable");
|
|
2017
|
+
const speculative = parse("Pages speculative");
|
|
2018
|
+
const available = free + inactive + purgeable + speculative;
|
|
2019
|
+
const total = os.totalmem();
|
|
2020
|
+
const used = total - available;
|
|
2021
|
+
let swapUsedBytes = 0;
|
|
2022
|
+
let swapTotalBytes = 0;
|
|
1769
2023
|
try {
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
2024
|
+
const swapOut = execFileSync("sysctl", ["-n", "vm.swapusage"], {
|
|
2025
|
+
timeout: 2e3,
|
|
2026
|
+
encoding: "utf-8"
|
|
1773
2027
|
});
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
2028
|
+
const totalMatch = swapOut.match(/total\s*=\s*([\d.]+)M/);
|
|
2029
|
+
const usedMatch = swapOut.match(/used\s*=\s*([\d.]+)M/);
|
|
2030
|
+
if (totalMatch) swapTotalBytes = parseFloat(totalMatch[1]) * 1024 * 1024;
|
|
2031
|
+
if (usedMatch) swapUsedBytes = parseFloat(usedMatch[1]) * 1024 * 1024;
|
|
2032
|
+
} catch {}
|
|
2033
|
+
provider._cachedMacMem = {
|
|
2034
|
+
percent: Math.round(used / total * 1e3) / 10,
|
|
2035
|
+
totalBytes: total,
|
|
2036
|
+
usedBytes: used,
|
|
2037
|
+
availableBytes: available,
|
|
2038
|
+
swapUsedBytes,
|
|
2039
|
+
swapTotalBytes
|
|
2040
|
+
};
|
|
2041
|
+
} catch {
|
|
2042
|
+
provider._cachedMacMem = null;
|
|
1784
2043
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
whereBetween: { atMs: [sinceMs + 1, Number.MAX_SAFE_INTEGER] },
|
|
1800
|
-
orderBy: {
|
|
1801
|
-
field: "atMs",
|
|
1802
|
-
direction: "asc"
|
|
1803
|
-
},
|
|
1804
|
-
limit: limitRows
|
|
1805
|
-
}
|
|
2044
|
+
}
|
|
2045
|
+
function getDiskSpaceInternal(dirPath) {
|
|
2046
|
+
return new Promise((resolve, reject) => {
|
|
2047
|
+
fs.statfs(dirPath, (err, stats) => {
|
|
2048
|
+
if (err) return reject(err);
|
|
2049
|
+
const totalBytes = stats.blocks * stats.bsize;
|
|
2050
|
+
const availableBytes = stats.bavail * stats.bsize;
|
|
2051
|
+
const usedBytes = totalBytes - stats.bfree * stats.bsize;
|
|
2052
|
+
resolve({
|
|
2053
|
+
path: dirPath,
|
|
2054
|
+
totalBytes,
|
|
2055
|
+
usedBytes,
|
|
2056
|
+
availableBytes,
|
|
2057
|
+
percent: totalBytes > 0 ? Math.round(usedBytes / totalBytes * 1e3) / 10 : 0
|
|
1806
2058
|
});
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
async function takeDiskIoSnapshot() {
|
|
2063
|
+
const now = Date.now();
|
|
2064
|
+
if (IS_LINUX) try {
|
|
2065
|
+
const data = fs.readFileSync("/proc/diskstats", "utf-8");
|
|
2066
|
+
let readOps = 0, readBytes = 0, writeOps = 0, writeBytes = 0;
|
|
2067
|
+
for (const line of data.split("\n")) {
|
|
2068
|
+
const parts = line.trim().split(/\s+/);
|
|
2069
|
+
if (parts.length < 14) continue;
|
|
2070
|
+
const devName = parts[2];
|
|
2071
|
+
if (/\d+$/.test(devName) && !/^nvme\d+n\d+$/.test(devName)) continue;
|
|
2072
|
+
readOps += parseInt(parts[3], 10) || 0;
|
|
2073
|
+
readBytes += (parseInt(parts[5], 10) || 0) * 512;
|
|
2074
|
+
writeOps += parseInt(parts[7], 10) || 0;
|
|
2075
|
+
writeBytes += (parseInt(parts[9], 10) || 0) * 512;
|
|
1820
2076
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2077
|
+
return {
|
|
2078
|
+
readBytes,
|
|
2079
|
+
writeBytes,
|
|
2080
|
+
readOps,
|
|
2081
|
+
writeOps,
|
|
2082
|
+
timestampMs: now
|
|
2083
|
+
};
|
|
2084
|
+
} catch {}
|
|
2085
|
+
return {
|
|
2086
|
+
readBytes: 0,
|
|
2087
|
+
writeBytes: 0,
|
|
2088
|
+
readOps: 0,
|
|
2089
|
+
writeOps: 0,
|
|
2090
|
+
timestampMs: now
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
async function takeNetworkIoSnapshot() {
|
|
2094
|
+
const now = Date.now();
|
|
2095
|
+
if (IS_LINUX) try {
|
|
2096
|
+
const data = fs.readFileSync("/proc/net/dev", "utf-8");
|
|
2097
|
+
let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
|
|
2098
|
+
for (const line of data.split("\n")) {
|
|
2099
|
+
const match = line.match(/^\s*(\w+):\s+(.+)$/);
|
|
2100
|
+
if (!match) continue;
|
|
2101
|
+
if (match[1] === "lo") continue;
|
|
2102
|
+
const cols = match[2].trim().split(/\s+/);
|
|
2103
|
+
rxBytes += parseInt(cols[0], 10) || 0;
|
|
2104
|
+
rxPackets += parseInt(cols[1], 10) || 0;
|
|
2105
|
+
rxErrors += parseInt(cols[2], 10) || 0;
|
|
2106
|
+
txBytes += parseInt(cols[8], 10) || 0;
|
|
2107
|
+
txPackets += parseInt(cols[9], 10) || 0;
|
|
2108
|
+
txErrors += parseInt(cols[10], 10) || 0;
|
|
1841
2109
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2110
|
+
return {
|
|
2111
|
+
rxBytes,
|
|
2112
|
+
txBytes,
|
|
2113
|
+
rxPackets,
|
|
2114
|
+
txPackets,
|
|
2115
|
+
rxErrors,
|
|
2116
|
+
txErrors,
|
|
2117
|
+
timestampMs: now
|
|
2118
|
+
};
|
|
2119
|
+
} catch {}
|
|
2120
|
+
if (IS_DARWIN) try {
|
|
2121
|
+
const stdout = await execAsync("netstat", ["-ib"]);
|
|
2122
|
+
let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
|
|
2123
|
+
for (const line of stdout.split("\n")) {
|
|
2124
|
+
const parts = line.trim().split(/\s+/);
|
|
2125
|
+
if (parts.length < 11) continue;
|
|
2126
|
+
if (parts[0] === "lo0" || parts[0] === "Name") continue;
|
|
2127
|
+
if (!parts[2]?.startsWith("Link#")) continue;
|
|
2128
|
+
rxPackets += parseInt(parts[4], 10) || 0;
|
|
2129
|
+
rxErrors += parseInt(parts[5], 10) || 0;
|
|
2130
|
+
rxBytes += parseInt(parts[6], 10) || 0;
|
|
2131
|
+
txPackets += parseInt(parts[7], 10) || 0;
|
|
2132
|
+
txErrors += parseInt(parts[8], 10) || 0;
|
|
2133
|
+
txBytes += parseInt(parts[9], 10) || 0;
|
|
1857
2134
|
}
|
|
1858
2135
|
return {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
2136
|
+
rxBytes,
|
|
2137
|
+
txBytes,
|
|
2138
|
+
rxPackets,
|
|
2139
|
+
txPackets,
|
|
2140
|
+
rxErrors,
|
|
2141
|
+
txErrors,
|
|
2142
|
+
timestampMs: now
|
|
1863
2143
|
};
|
|
2144
|
+
} catch {}
|
|
2145
|
+
return {
|
|
2146
|
+
rxBytes: 0,
|
|
2147
|
+
txBytes: 0,
|
|
2148
|
+
rxPackets: 0,
|
|
2149
|
+
txPackets: 0,
|
|
2150
|
+
rxErrors: 0,
|
|
2151
|
+
txErrors: 0,
|
|
2152
|
+
timestampMs: now
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
async function getCpuTemperatureInternal() {
|
|
2156
|
+
if (IS_LINUX) for (const p of ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]) try {
|
|
2157
|
+
const raw = fs.readFileSync(p, "utf-8").trim();
|
|
2158
|
+
const millideg = parseInt(raw, 10);
|
|
2159
|
+
if (!isNaN(millideg)) return Math.round(millideg / 100) / 10;
|
|
2160
|
+
} catch {
|
|
2161
|
+
continue;
|
|
1864
2162
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2163
|
+
if (IS_DARWIN) try {
|
|
2164
|
+
const match = (await execAsync("sudo", [
|
|
2165
|
+
"-n",
|
|
2166
|
+
"powermetrics",
|
|
2167
|
+
"--samplers",
|
|
2168
|
+
"smc",
|
|
2169
|
+
"-i",
|
|
2170
|
+
"1",
|
|
2171
|
+
"-n",
|
|
2172
|
+
"1"
|
|
2173
|
+
], 3e3)).match(/CPU die temperature:\s+([\d.]+)\s*C/);
|
|
2174
|
+
if (match) return Math.round(parseFloat(match[1]) * 10) / 10;
|
|
2175
|
+
} catch {}
|
|
2176
|
+
return null;
|
|
2177
|
+
}
|
|
2178
|
+
async function getGpuInfoInternal() {
|
|
2179
|
+
if (IS_DARWIN) try {
|
|
2180
|
+
const utilMatch = (await execAsync("ioreg", [
|
|
2181
|
+
"-r",
|
|
2182
|
+
"-d",
|
|
2183
|
+
"1",
|
|
2184
|
+
"-c",
|
|
2185
|
+
"IOAccelerator"
|
|
2186
|
+
])).match(/"GPU Core Utilization"\s*=\s*(\d+)/);
|
|
2187
|
+
const util = utilMatch ? parseInt(utilMatch[1], 10) : 0;
|
|
2188
|
+
const utilPct = util > 1e3 ? Math.round(util / 1e7) : util;
|
|
2189
|
+
return {
|
|
2190
|
+
utilization: Math.min(100, utilPct),
|
|
2191
|
+
model: "Apple Silicon GPU",
|
|
2192
|
+
memoryUsedBytes: 0,
|
|
2193
|
+
memoryTotalBytes: 0,
|
|
2194
|
+
temperature: null
|
|
2195
|
+
};
|
|
2196
|
+
} catch {}
|
|
2197
|
+
if (IS_LINUX) return readNvidiaGpu({
|
|
2198
|
+
findOnPath: (binary) => findOnPath(binary, process.env["PATH"]),
|
|
2199
|
+
exec: (command, args) => execAsync(command, [...args])
|
|
2200
|
+
});
|
|
2201
|
+
return null;
|
|
2202
|
+
}
|
|
2203
|
+
function getProcessResources() {
|
|
2204
|
+
let openFds = 0;
|
|
2205
|
+
let threadCount = 0;
|
|
2206
|
+
if (IS_LINUX) {
|
|
1868
2207
|
try {
|
|
1869
|
-
|
|
1870
|
-
} catch
|
|
1871
|
-
this.logger.warn("load series count failed — the row cap is not enforced this pass", { meta: { error: errMsg(err) } });
|
|
1872
|
-
return null;
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
/**
|
|
1876
|
-
* Delete this node's rows older than `cutoff`, at most one page's worth.
|
|
1877
|
-
*
|
|
1878
|
-
* The page is the whole point. `deleteWhere({ atMs: [0, cutoff] })` on its
|
|
1879
|
-
* own is one statement but an UNBOUNDED one — a first pass after a retention
|
|
1880
|
-
* change would delete millions of rows inside a single stalling transaction.
|
|
1881
|
-
* So the page is read first (keys only, ordered by the index), its last
|
|
1882
|
-
* `atMs` becomes the EFFECTIVE cutoff, and the delete is bounded by it.
|
|
1883
|
-
*/
|
|
1884
|
-
async pruneNodeToCutoff(nodeId, cutoff) {
|
|
2208
|
+
openFds = fs.readdirSync(`/proc/${process.pid}/fd`).length;
|
|
2209
|
+
} catch {}
|
|
1885
2210
|
try {
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
const
|
|
1904
|
-
if (!
|
|
1905
|
-
deleted: 0,
|
|
1906
|
-
examined: page.length
|
|
1907
|
-
};
|
|
1908
|
-
const { deleted } = await this.store.deleteWhere.mutate({
|
|
1909
|
-
collection: LOAD_SERIES_COLLECTION,
|
|
1910
|
-
filter: {
|
|
1911
|
-
where: { nodeId },
|
|
1912
|
-
whereBetween: { atMs: [0, effectiveCutoff] }
|
|
1913
|
-
}
|
|
1914
|
-
});
|
|
1915
|
-
return {
|
|
1916
|
-
deleted,
|
|
1917
|
-
examined: page.length
|
|
1918
|
-
};
|
|
1919
|
-
} catch (err) {
|
|
1920
|
-
this.logger.warn("load series age prune failed — the table keeps growing this pass", { meta: {
|
|
1921
|
-
nodeId,
|
|
1922
|
-
cutoff,
|
|
1923
|
-
error: errMsg(err)
|
|
1924
|
-
} });
|
|
2211
|
+
const match = fs.readFileSync(`/proc/${process.pid}/status`, "utf-8").match(/Threads:\s+(\d+)/);
|
|
2212
|
+
if (match) threadCount = parseInt(match[1], 10);
|
|
2213
|
+
} catch {}
|
|
2214
|
+
}
|
|
2215
|
+
const nodeProcess = process;
|
|
2216
|
+
return {
|
|
2217
|
+
openFds,
|
|
2218
|
+
threadCount,
|
|
2219
|
+
activeHandles: (nodeProcess._getActiveHandles?.() ?? []).length,
|
|
2220
|
+
activeRequests: (nodeProcess._getActiveRequests?.() ?? []).length
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
function getPressure(resource) {
|
|
2224
|
+
if (!IS_LINUX) return null;
|
|
2225
|
+
try {
|
|
2226
|
+
const data = fs.readFileSync(`/proc/pressure/${resource}`, "utf-8");
|
|
2227
|
+
const parseLine = (prefix) => {
|
|
2228
|
+
const match = data.match(new RegExp(`${prefix} avg10=(\\d+\\.\\d+) avg60=(\\d+\\.\\d+) avg300=(\\d+\\.\\d+)`));
|
|
2229
|
+
if (!match) return null;
|
|
1925
2230
|
return {
|
|
1926
|
-
|
|
1927
|
-
|
|
2231
|
+
avg10: parseFloat(match[1]),
|
|
2232
|
+
avg60: parseFloat(match[2]),
|
|
2233
|
+
avg300: parseFloat(match[3])
|
|
1928
2234
|
};
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
* Drop the oldest rows across the known nodes until `excess` is covered.
|
|
1933
|
-
*
|
|
1934
|
-
* Same bounded-page technique, walked per node so the one index serves it.
|
|
1935
|
-
* The node holding the oldest rows pays first, which is also the node
|
|
1936
|
-
* producing the pressure when a runaway process count is the cause.
|
|
1937
|
-
*/
|
|
1938
|
-
async pruneOldestRows(nodeIds, excess) {
|
|
1939
|
-
let remaining = Math.min(excess, this.prunePageRows);
|
|
1940
|
-
let deleted = 0;
|
|
1941
|
-
let examined = 0;
|
|
1942
|
-
for (const nodeId of nodeIds) {
|
|
1943
|
-
if (remaining <= 0) break;
|
|
1944
|
-
try {
|
|
1945
|
-
const page = await this.store.query.query({
|
|
1946
|
-
collection: LOAD_SERIES_COLLECTION,
|
|
1947
|
-
filter: {
|
|
1948
|
-
where: { nodeId },
|
|
1949
|
-
orderBy: {
|
|
1950
|
-
field: "atMs",
|
|
1951
|
-
direction: "asc"
|
|
1952
|
-
},
|
|
1953
|
-
limit: remaining
|
|
1954
|
-
},
|
|
1955
|
-
columns: ["atMs"]
|
|
1956
|
-
});
|
|
1957
|
-
examined += page.length;
|
|
1958
|
-
if (page.length === 0) continue;
|
|
1959
|
-
const cutoff = Number(page.at(-1)?.data["atMs"]);
|
|
1960
|
-
if (!Number.isFinite(cutoff)) continue;
|
|
1961
|
-
const result = await this.store.deleteWhere.mutate({
|
|
1962
|
-
collection: LOAD_SERIES_COLLECTION,
|
|
1963
|
-
filter: {
|
|
1964
|
-
where: { nodeId },
|
|
1965
|
-
whereBetween: { atMs: [0, cutoff] }
|
|
1966
|
-
}
|
|
1967
|
-
});
|
|
1968
|
-
deleted += result.deleted;
|
|
1969
|
-
remaining -= result.deleted;
|
|
1970
|
-
} catch (err) {
|
|
1971
|
-
this.logger.warn("load series cap prune failed — the cap is not enforced this pass", { meta: {
|
|
1972
|
-
nodeId,
|
|
1973
|
-
error: errMsg(err)
|
|
1974
|
-
} });
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
2235
|
+
};
|
|
2236
|
+
const some = parseLine("some");
|
|
2237
|
+
if (!some) return null;
|
|
1977
2238
|
return {
|
|
1978
|
-
|
|
1979
|
-
|
|
2239
|
+
some,
|
|
2240
|
+
full: parseLine("full")
|
|
1980
2241
|
};
|
|
2242
|
+
} catch {
|
|
2243
|
+
return null;
|
|
1981
2244
|
}
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2245
|
+
}
|
|
2246
|
+
function execAsync(cmd, args, timeoutMs = 5e3) {
|
|
2247
|
+
return new Promise((resolve, reject) => {
|
|
2248
|
+
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
|
|
2249
|
+
if (err) return reject(err);
|
|
2250
|
+
resolve(stdout);
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Parse one `/proc/<pid>/stat` line. `null` for a line that does not parse —
|
|
2256
|
+
* a process that exited between the readdir and the read leaves a truncated
|
|
2257
|
+
* or empty file, and that is normal, not an error.
|
|
2258
|
+
*/
|
|
2259
|
+
function parseProcStat(line) {
|
|
2260
|
+
const close = line.lastIndexOf(")");
|
|
2261
|
+
const open = line.indexOf("(");
|
|
2262
|
+
if (close < 0 || open < 0 || close < open) return null;
|
|
2263
|
+
const pid = Number(line.slice(0, open).trim());
|
|
2264
|
+
const comm = line.slice(open + 1, close);
|
|
2265
|
+
const rest = line.slice(close + 1).trim().split(/\s+/);
|
|
2266
|
+
const state = rest[0];
|
|
2267
|
+
const ppid = Number(rest[1]);
|
|
2268
|
+
const pgid = Number(rest[2]);
|
|
2269
|
+
const utime = Number(rest[11]);
|
|
2270
|
+
const stime = Number(rest[12]);
|
|
2271
|
+
const startTicks = Number(rest[19]);
|
|
2272
|
+
const rssPages = Number(rest[21]);
|
|
2273
|
+
if (state === void 0 || state === "") return null;
|
|
1992
2274
|
if (![
|
|
1993
|
-
atMs,
|
|
1994
2275
|
pid,
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
return Number.isFinite(n) ? n : null;
|
|
2003
|
-
};
|
|
2276
|
+
ppid,
|
|
2277
|
+
pgid,
|
|
2278
|
+
utime,
|
|
2279
|
+
stime,
|
|
2280
|
+
startTicks,
|
|
2281
|
+
rssPages
|
|
2282
|
+
].every(Number.isFinite)) return null;
|
|
2004
2283
|
return {
|
|
2005
|
-
nodeId,
|
|
2006
|
-
atMs,
|
|
2007
2284
|
pid,
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2285
|
+
comm,
|
|
2286
|
+
state,
|
|
2287
|
+
ppid,
|
|
2288
|
+
pgid,
|
|
2289
|
+
ticks: utime + stime,
|
|
2290
|
+
startTicks,
|
|
2291
|
+
rssPages
|
|
2014
2292
|
};
|
|
2015
2293
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2294
|
+
function statusKb(status, key) {
|
|
2295
|
+
const match = status.match(new RegExp(`^${key}:\\s+(\\d+)`, "m"));
|
|
2296
|
+
return match ? parseInt(match[1], 10) * 1024 : void 0;
|
|
2297
|
+
}
|
|
2298
|
+
/** Read `VmRSS` / `RssAnon` / `RssFile` (kB in the file) out of `/proc/<pid>/status`. */
|
|
2299
|
+
function parseProcStatusRss(status) {
|
|
2300
|
+
return {
|
|
2301
|
+
rssBytes: statusKb(status, "VmRSS"),
|
|
2302
|
+
privateBytes: statusKb(status, "RssAnon"),
|
|
2303
|
+
sharedBytes: statusKb(status, "RssFile")
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
/** Seconds the process has been alive — `ps etime`, whole seconds. */
|
|
2307
|
+
function elapsedSecSince(input) {
|
|
2308
|
+
return Math.max(0, Math.floor(input.uptimeSec - input.startTicks / 100));
|
|
2309
|
+
}
|
|
2018
2310
|
/**
|
|
2019
|
-
*
|
|
2020
|
-
* honest.
|
|
2021
|
-
*
|
|
2022
|
-
* ## Where they live, and why there
|
|
2023
|
-
*
|
|
2024
|
-
* In `native-metrics`'s OWN global settings document — the one that already
|
|
2025
|
-
* exists and already carries `samplingIntervalMs`. Fields on a document, never
|
|
2026
|
-
* a method per knob: `system.getLoggingSettings` / `setLoggingSettings` set
|
|
2027
|
-
* that precedent and it holds here.
|
|
2028
|
-
*
|
|
2029
|
-
* They are NOT in the logging document, and that is a decision rather than an
|
|
2030
|
-
* omission. That document is about levels and diagnostic windows; a metrics
|
|
2031
|
-
* cadence and a table's retention are neither. Two documents that both claim a
|
|
2032
|
-
* knob is how this repo has already shipped a switch nobody read.
|
|
2033
|
-
*
|
|
2034
|
-
* They are cluster-wide, not per-node. Every node emits on the same cadence or
|
|
2035
|
-
* the fleet's series cannot be laid over each other, and the table is single
|
|
2036
|
-
* and hub-resident, so a per-node retention would be a promise nothing could
|
|
2037
|
-
* keep.
|
|
2038
|
-
*
|
|
2039
|
-
* ## Refused, not clamped
|
|
2311
|
+
* `ps pcpu`: CPU time over elapsed time, as a percentage with one decimal.
|
|
2040
2312
|
*
|
|
2041
|
-
*
|
|
2042
|
-
* the
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
2313
|
+
* This is deliberately the LIFETIME average `ps` prints, not an instantaneous
|
|
2314
|
+
* rate: the columns fed from it have always shown that quantity, and the
|
|
2315
|
+
* instantaneous split is a different number with a different owner
|
|
2316
|
+
* (`cpuMainPercent` / `cpuGcPercent` from `thread-cpu-sampler.ts`). A process
|
|
2317
|
+
* with no elapsed window yet reports 0, never a division by zero.
|
|
2045
2318
|
*/
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2319
|
+
function lifetimeCpuPercent(input) {
|
|
2320
|
+
const elapsedSec = input.uptimeSec - input.startTicks / 100;
|
|
2321
|
+
if (!(elapsedSec > 0)) return 0;
|
|
2322
|
+
const cpuSec = input.ticks / 100;
|
|
2323
|
+
return Math.round(cpuSec / elapsedSec * 1e3) / 10;
|
|
2324
|
+
}
|
|
2325
|
+
var PID_RE = /^\d+$/;
|
|
2326
|
+
var nodeProcReader = {
|
|
2327
|
+
listPids: async () => (await readdir("/proc")).filter((entry) => PID_RE.test(entry)).map((entry) => Number(entry)),
|
|
2328
|
+
readStat: (pid) => readFile(`/proc/${pid}/stat`, "latin1"),
|
|
2329
|
+
readStatus: (pid) => readFile(`/proc/${pid}/status`, "latin1"),
|
|
2330
|
+
readCmdline: (pid) => readFile(`/proc/${pid}/cmdline`, "latin1"),
|
|
2331
|
+
readUptimeSec: async () => {
|
|
2332
|
+
const [first] = (await readFile("/proc/uptime", "latin1")).trim().split(/\s+/);
|
|
2333
|
+
const seconds = Number(first);
|
|
2334
|
+
if (!Number.isFinite(seconds)) throw new Error(`/proc/uptime unreadable: ${String(first)}`);
|
|
2335
|
+
return seconds;
|
|
2051
2336
|
}
|
|
2052
2337
|
};
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
return
|
|
2338
|
+
/** `ps command=`: argv joined by spaces, or `[comm]` when there is no argv. */
|
|
2339
|
+
function commandFromCmdline(cmdline, comm) {
|
|
2340
|
+
const joined = cmdline.split("\0").filter((part) => part.length > 0).join(" ");
|
|
2341
|
+
return joined.length > 0 ? joined : `[${comm}]`;
|
|
2057
2342
|
}
|
|
2343
|
+
//#endregion
|
|
2344
|
+
//#region src/builtins/native-metrics/proc-process-table.ts
|
|
2058
2345
|
/**
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2346
|
+
* The node's process table — every row `ps -eo pid,ppid,pgid,pcpu,rss,etime,
|
|
2347
|
+
* command` printed — read from `/proc` on Linux, with no fork.
|
|
2348
|
+
*
|
|
2349
|
+
* Why the fork mattered, and the numbers, are in `process/proc-stat.ts`. What
|
|
2350
|
+
* this file adds is the WHOLE-TABLE read: one `readdir` of `/proc`, then per
|
|
2351
|
+
* pid `stat`, `status` and `cmdline`, all through `fs/promises` so the reads
|
|
2352
|
+
* land on the libuv pool and the main thread pays only the parse. Measured in
|
|
2353
|
+
* the hub container (70 processes, D452): 2–4 ms wall for the walk, against
|
|
2354
|
+
* 3–4 ms for `ps` itself — the fork that USED to precede it was the cost.
|
|
2355
|
+
*
|
|
2356
|
+
* A pid that vanishes between the listing and its reads is omitted whole.
|
|
2357
|
+
* Never a partial row: a row with a command and no memory would render as a
|
|
2358
|
+
* process using nothing, which is a different claim from "gone".
|
|
2063
2359
|
*/
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2360
|
+
var execFileAsync = promisify(execFile);
|
|
2361
|
+
async function readProcRow(pid, uptimeSec, reader) {
|
|
2362
|
+
let stat;
|
|
2363
|
+
let cmdline;
|
|
2364
|
+
let status;
|
|
2365
|
+
try {
|
|
2366
|
+
[stat, cmdline, status] = await Promise.all([
|
|
2367
|
+
reader.readStat(pid),
|
|
2368
|
+
reader.readCmdline(pid),
|
|
2369
|
+
reader.readStatus(pid)
|
|
2370
|
+
]);
|
|
2371
|
+
} catch {
|
|
2372
|
+
return null;
|
|
2074
2373
|
}
|
|
2374
|
+
const parsed = parseProcStat(stat);
|
|
2375
|
+
if (parsed === null) return null;
|
|
2376
|
+
const rss = parseProcStatusRss(status);
|
|
2075
2377
|
return {
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
function projectLoadSeriesCost(input) {
|
|
2091
|
-
const samplesPerNodeWindow = input.config.retentionHours * 3600 / input.config.cadenceSec;
|
|
2092
|
-
const intendedRows = Math.round(samplesPerNodeWindow * Math.max(input.observedProcessRows, 0));
|
|
2093
|
-
const boundedRows = Math.min(intendedRows, input.config.maxRows);
|
|
2094
|
-
return {
|
|
2095
|
-
intendedRows,
|
|
2096
|
-
boundedRows,
|
|
2097
|
-
estimatedBytes: boundedRows * 87,
|
|
2098
|
-
capBites: intendedRows > input.config.maxRows
|
|
2378
|
+
pid: parsed.pid,
|
|
2379
|
+
ppid: parsed.ppid,
|
|
2380
|
+
pgid: parsed.pgid,
|
|
2381
|
+
cpuPercent: lifetimeCpuPercent({
|
|
2382
|
+
ticks: parsed.ticks,
|
|
2383
|
+
startTicks: parsed.startTicks,
|
|
2384
|
+
uptimeSec
|
|
2385
|
+
}),
|
|
2386
|
+
memoryRssBytes: rss.rssBytes ?? 0,
|
|
2387
|
+
uptimeSec: elapsedSecSince({
|
|
2388
|
+
startTicks: parsed.startTicks,
|
|
2389
|
+
uptimeSec
|
|
2390
|
+
}),
|
|
2391
|
+
command: commandFromCmdline(cmdline, parsed.comm)
|
|
2099
2392
|
};
|
|
2100
2393
|
}
|
|
2101
|
-
/**
|
|
2102
|
-
function
|
|
2103
|
-
const
|
|
2104
|
-
|
|
2105
|
-
const capped = projection.boundedRows.toLocaleString("en-US");
|
|
2106
|
-
if (!projection.capBites) return `This configuration retains ~${rows} rows ≈ ${mib} MiB on the hub database (NVMe cache), measured at 87 bytes per row.`;
|
|
2107
|
-
return `This configuration WANTS ~${rows} rows, above the ${projection.boundedRows.toLocaleString("en-US")}-row hard cap. The table will hold ~${capped} rows ≈ ${mib} MiB and the OLDEST samples will be evicted, so the effective window is shorter than the retention you asked for.`;
|
|
2394
|
+
/** Every process in `/proc`, as the rows `ps -eo …` would have printed. */
|
|
2395
|
+
async function readProcProcessTable(reader = nodeProcReader) {
|
|
2396
|
+
const [pids, uptimeSec] = await Promise.all([reader.listPids(), reader.readUptimeSec()]);
|
|
2397
|
+
return (await Promise.all(pids.map((pid) => readProcRow(pid, uptimeSec, reader)))).filter((row) => row !== null);
|
|
2108
2398
|
}
|
|
2109
|
-
//#endregion
|
|
2110
|
-
//#region src/builtins/native-metrics/load-series-reader.ts
|
|
2111
|
-
/**
|
|
2112
|
-
* The ONE reader over both retention tiers.
|
|
2113
|
-
*
|
|
2114
|
-
* The hot window is the in-memory ring; the cold one is
|
|
2115
|
-
* `metrics:node-load-samples`. A caller never learns which tier a point came
|
|
2116
|
-
* from, and that is a hard requirement rather than a nicety: a second reader
|
|
2117
|
-
* is how two charts start disagreeing about the same node, and it is how "the
|
|
2118
|
-
* ring says X, the table says Y" becomes a question nobody can answer.
|
|
2119
|
-
*
|
|
2120
|
-
* Three things happen here, in this order, and each one is load-bearing:
|
|
2121
|
-
*
|
|
2122
|
-
* 1. **Merge and dedupe on `atMs`.** The two tiers OVERLAP by construction —
|
|
2123
|
-
* the ring holds the last 180 snapshots and the table holds every snapshot
|
|
2124
|
-
* the ring accepted, including those same 180. A snapshot present in both
|
|
2125
|
-
* must contribute exactly once. Identity is `atMs`, because one node emits
|
|
2126
|
-
* one snapshot per tick and a timestamp already held IS that snapshot. The
|
|
2127
|
-
* HOT copy wins on a tie: it is the payload as it arrived, while the cold
|
|
2128
|
-
* one has been through the storage encode.
|
|
2129
|
-
* 2. **Fold per function.** The chart draws one series per addon, so the fold
|
|
2130
|
-
* happens here rather than in the browser.
|
|
2131
|
-
* 3. **Reduce, preserving the extremes.** See `load-series-fold.ts` for why
|
|
2132
|
-
* the representative of a bucket is its MAX and never its mean.
|
|
2133
|
-
*
|
|
2134
|
-
* A GAP stays a gap through all three. Emission is fixed-cadence, so a missing
|
|
2135
|
-
* interval means one thing — nobody reported — and nothing here fills it in.
|
|
2136
|
-
*/
|
|
2137
2399
|
/**
|
|
2138
|
-
*
|
|
2139
|
-
*
|
|
2140
|
-
* Exported because the dedup is the property worth testing on its own: a
|
|
2141
|
-
* doubled point is invisible in a total and obvious only as a chart that reads
|
|
2142
|
-
* twice as loud as the machine.
|
|
2400
|
+
* Parse `ps etime` → seconds. `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`.
|
|
2401
|
+
* Only the `ps` path needs this; `/proc` gives the number directly.
|
|
2143
2402
|
*/
|
|
2144
|
-
function
|
|
2145
|
-
const
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2403
|
+
function parseEtime(etime) {
|
|
2404
|
+
const dashParts = etime.split("-");
|
|
2405
|
+
let days = 0;
|
|
2406
|
+
let hms = etime;
|
|
2407
|
+
if (dashParts.length === 2) {
|
|
2408
|
+
days = parseInt(dashParts[0], 10) || 0;
|
|
2409
|
+
hms = dashParts[1];
|
|
2410
|
+
}
|
|
2411
|
+
const parts = hms.split(":").map((p) => parseInt(p, 10) || 0);
|
|
2412
|
+
let hours = 0;
|
|
2413
|
+
let minutes = 0;
|
|
2414
|
+
let seconds = 0;
|
|
2415
|
+
if (parts.length === 3) [hours, minutes, seconds] = parts;
|
|
2416
|
+
else if (parts.length === 2) [minutes, seconds] = parts;
|
|
2417
|
+
else if (parts.length === 1) [seconds] = parts;
|
|
2418
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
2149
2419
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2420
|
+
var PS_ROW_RE = /^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/;
|
|
2421
|
+
/** Parse the output of `ps -eo pid=,ppid=,pgid=,pcpu=,rss=,etime=,command=`. */
|
|
2422
|
+
function parsePsTable(stdout) {
|
|
2423
|
+
const rows = [];
|
|
2424
|
+
for (const line of stdout.split("\n")) {
|
|
2425
|
+
const match = line.trim().match(PS_ROW_RE);
|
|
2426
|
+
if (!match) continue;
|
|
2427
|
+
const [, pidS, ppidS, pgidS, cpuS, rssS, etimeS, command] = match;
|
|
2428
|
+
rows.push({
|
|
2429
|
+
pid: parseInt(pidS, 10),
|
|
2430
|
+
ppid: parseInt(ppidS, 10),
|
|
2431
|
+
pgid: parseInt(pgidS, 10),
|
|
2432
|
+
cpuPercent: Math.round(parseFloat(cpuS) * 10) / 10,
|
|
2433
|
+
memoryRssBytes: parseInt(rssS, 10) * 1024,
|
|
2434
|
+
uptimeSec: parseEtime(etimeS),
|
|
2435
|
+
command
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
return rows;
|
|
2439
|
+
}
|
|
2440
|
+
/** The fork. Only reached on a platform without `/proc`. */
|
|
2441
|
+
async function spawnPsTable() {
|
|
2442
|
+
const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,pgid=,pcpu=,rss=,etime=,command="], {
|
|
2443
|
+
timeout: 5e3,
|
|
2444
|
+
maxBuffer: 8 * 1024 * 1024
|
|
2445
|
+
});
|
|
2446
|
+
return parsePsTable(stdout);
|
|
2153
2447
|
}
|
|
2448
|
+
var defaultDeps = {
|
|
2449
|
+
platform: platform(),
|
|
2450
|
+
reader: nodeProcReader,
|
|
2451
|
+
spawnPs: spawnPsTable
|
|
2452
|
+
};
|
|
2154
2453
|
/**
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2157
|
-
*
|
|
2454
|
+
* The process table, from `/proc` where it exists and from `ps` elsewhere.
|
|
2455
|
+
* Throws only when the chosen source cannot answer at all; the caller logs it
|
|
2456
|
+
* as a skipped interval.
|
|
2158
2457
|
*/
|
|
2159
|
-
function
|
|
2160
|
-
|
|
2161
|
-
if (samples.length === 0) return {
|
|
2162
|
-
series: [],
|
|
2163
|
-
bucketMs: input.cadenceMs,
|
|
2164
|
-
retainedSamples: 0,
|
|
2165
|
-
oldestAtMs: null
|
|
2166
|
-
};
|
|
2167
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
2168
|
-
for (const sample of samples) for (const bucket of foldSnapshotByFunction(toFoldRows(sample.processes), sample.atMs)) {
|
|
2169
|
-
const held = byKey.get(bucket.key);
|
|
2170
|
-
if (held === void 0) byKey.set(bucket.key, {
|
|
2171
|
-
kind: bucket.kind,
|
|
2172
|
-
points: [bucket.point]
|
|
2173
|
-
});
|
|
2174
|
-
else held.points.push(bucket.point);
|
|
2175
|
-
}
|
|
2176
|
-
const oldestAtMs = samples[0]?.atMs ?? null;
|
|
2177
|
-
const newestAtMs = samples.at(-1)?.atMs ?? oldestAtMs;
|
|
2178
|
-
const spanMs = oldestAtMs === null || newestAtMs === null ? 0 : newestAtMs - oldestAtMs + input.cadenceMs;
|
|
2179
|
-
const bucketMs = input.maxPoints === void 0 ? input.cadenceMs : resolveBucketMs(spanMs, input.cadenceMs, input.maxPoints);
|
|
2180
|
-
const origin = oldestAtMs ?? 0;
|
|
2181
|
-
const series = [...byKey.entries()].map(([key, held]) => ({
|
|
2182
|
-
key,
|
|
2183
|
-
kind: held.kind,
|
|
2184
|
-
points: bucketMs > input.cadenceMs ? reducePoints(held.points, bucketMs, origin) : held.points
|
|
2185
|
-
}));
|
|
2186
|
-
const weight = (s) => {
|
|
2187
|
-
const last = s.points.at(-1);
|
|
2188
|
-
if (last === void 0) return -1;
|
|
2189
|
-
if (last.cpuMainPercent !== null && last.cpuGcPercent !== null) return last.cpuMainPercent + last.cpuGcPercent;
|
|
2190
|
-
return last.cpuLifetimePercent;
|
|
2191
|
-
};
|
|
2192
|
-
return {
|
|
2193
|
-
series: series.toSorted((a, b) => {
|
|
2194
|
-
const d = weight(b) - weight(a);
|
|
2195
|
-
return d !== 0 ? d : a.key.localeCompare(b.key);
|
|
2196
|
-
}),
|
|
2197
|
-
bucketMs,
|
|
2198
|
-
retainedSamples: samples.length,
|
|
2199
|
-
oldestAtMs
|
|
2200
|
-
};
|
|
2458
|
+
function readProcessTable(deps = defaultDeps) {
|
|
2459
|
+
return deps.platform === "linux" ? readProcProcessTable(deps.reader) : deps.spawnPs();
|
|
2201
2460
|
}
|
|
2202
2461
|
//#endregion
|
|
2203
2462
|
//#region src/builtins/native-metrics/process-classification.ts
|
|
@@ -2448,7 +2707,6 @@ var ThreadCpuTracker = class {
|
|
|
2448
2707
|
};
|
|
2449
2708
|
//#endregion
|
|
2450
2709
|
//#region src/builtins/native-metrics/native-metrics.addon.ts
|
|
2451
|
-
var execFileAsync = promisify(execFile);
|
|
2452
2710
|
/**
|
|
2453
2711
|
* Cadence for the per-node metric snapshot bus events. ~5 s matches
|
|
2454
2712
|
* the existing UI polling cadence (ProcessesTab, ProcessResources)
|
|
@@ -2457,25 +2715,13 @@ var execFileAsync = promisify(execFile);
|
|
|
2457
2715
|
*/
|
|
2458
2716
|
var METRICS_SNAPSHOT_INTERVAL_MS = 5e3;
|
|
2459
2717
|
/**
|
|
2460
|
-
*
|
|
2461
|
-
* operator's `loadSeriesCadenceSec` (5-60 s, 10 by default).
|
|
2462
|
-
*
|
|
2463
|
-
* It used to emit only ON A CHANGE, with a 60 s fallback heartbeat, and the
|
|
2464
|
-
* dedup is gone rather than tuned. A conditional emit makes a hole in the
|
|
2465
|
-
* series mean two different things — "nothing changed" and "nobody reported"
|
|
2466
|
-
* — and telling those apart is the entire reason an operator opens this chart
|
|
2467
|
-
* after a spike. Now a gap means exactly one thing.
|
|
2468
|
-
*
|
|
2469
|
-
* It is not free: the emit rate is deterministic, so the durable table's row
|
|
2470
|
-
* count is too (nodes x processes / cadence, no discount for a quiet fleet).
|
|
2471
|
-
* The measured cost of that is in `docs/architecture/load-ledger.md`, and it
|
|
2472
|
-
* is why the retention default is six hours rather than three days.
|
|
2473
|
-
*/
|
|
2474
|
-
/**
|
|
2475
|
-
* Force a metrics-snapshot emit at least every 60s even when the
|
|
2718
|
+
* Force a RESOURCES-snapshot emit at least every 60s even when the
|
|
2476
2719
|
* coarsened payload looks unchanged. Without this an idle node
|
|
2477
2720
|
* (steady CPU within ±5%, no process churn) goes silent on the
|
|
2478
2721
|
* bus and the UI's "node alive" chip can stale out.
|
|
2722
|
+
*
|
|
2723
|
+
* The PROCESS-tree snapshot has no such heartbeat and no dedup: it is
|
|
2724
|
+
* fixed-cadence — see `startProcessSnapshotTimer`.
|
|
2479
2725
|
*/
|
|
2480
2726
|
var METRICS_SNAPSHOT_HEARTBEAT_MS = 6e4;
|
|
2481
2727
|
/**
|
|
@@ -2790,7 +3036,23 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
|
|
|
2790
3036
|
} });
|
|
2791
3037
|
}
|
|
2792
3038
|
}
|
|
2793
|
-
/**
|
|
3039
|
+
/**
|
|
3040
|
+
* (Re)arm the process-tree timer at the configured interval.
|
|
3041
|
+
*
|
|
3042
|
+
* The process-tree snapshot is FIXED-CADENCE, and its cadence is the
|
|
3043
|
+
* operator's `loadSeriesCadenceSec` (5-60 s, 10 by default).
|
|
3044
|
+
*
|
|
3045
|
+
* It used to emit only ON A CHANGE, with a 60 s fallback heartbeat, and the
|
|
3046
|
+
* dedup is gone rather than tuned. A conditional emit makes a hole in the
|
|
3047
|
+
* series mean two different things — "nothing changed" and "nobody reported"
|
|
3048
|
+
* — and telling those apart is the entire reason an operator opens this chart
|
|
3049
|
+
* after a spike. Now a gap means exactly one thing.
|
|
3050
|
+
*
|
|
3051
|
+
* It is not free: the emit rate is deterministic, so the durable table's row
|
|
3052
|
+
* count is too (nodes x processes / cadence, no discount for a quiet fleet).
|
|
3053
|
+
* The measured cost of that is in `docs/architecture/load-ledger.md`, and it
|
|
3054
|
+
* is why the retention default is six hours rather than three days.
|
|
3055
|
+
*/
|
|
2794
3056
|
startProcessSnapshotTimer() {
|
|
2795
3057
|
if (this.processSnapshotTimer) clearInterval(this.processSnapshotTimer);
|
|
2796
3058
|
this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), this.loadConfig.cadenceSec * 1e3);
|
|
@@ -2852,7 +3114,8 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
|
|
|
2852
3114
|
/**
|
|
2853
3115
|
* Emit one `metrics.node-processes-snapshot` for this node.
|
|
2854
3116
|
*
|
|
2855
|
-
* Heavy — a full
|
|
3117
|
+
* Heavy — a full process-table read (`/proc` on Linux, `ps -eo` elsewhere;
|
|
3118
|
+
* `proc-process-table.ts`) plus a `$process.list` broker call — and
|
|
2856
3119
|
* UNCONDITIONAL. The change-detection that used to guard it is gone: with a
|
|
2857
3120
|
* fixed cadence a missing interval means exactly one thing, and that is the
|
|
2858
3121
|
* property an operator investigating a spike is actually looking for.
|
|
@@ -2957,40 +3220,17 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
|
|
|
2957
3220
|
});
|
|
2958
3221
|
}
|
|
2959
3222
|
/**
|
|
2960
|
-
*
|
|
2961
|
-
*
|
|
2962
|
-
*
|
|
2963
|
-
*
|
|
2964
|
-
*
|
|
3223
|
+
* Every pid + command + resource stats on this node.
|
|
3224
|
+
*
|
|
3225
|
+
* From `/proc` on Linux — no fork. This used to be a `ps -eo` child on every
|
|
3226
|
+
* tick, i.e. a synchronous `fork()` of hub-main at p50 91 ms (D441, D452).
|
|
3227
|
+
* `ps` remains the source only on a platform without `/proc`.
|
|
2965
3228
|
*/
|
|
2966
|
-
/** Raw `ps` scan returning every pid + command + resource stats. */
|
|
2967
3229
|
async runPs() {
|
|
2968
3230
|
try {
|
|
2969
|
-
|
|
2970
|
-
timeout: 5e3,
|
|
2971
|
-
maxBuffer: 8 * 1024 * 1024
|
|
2972
|
-
});
|
|
2973
|
-
const lines = stdout.split("\n");
|
|
2974
|
-
const rows = [];
|
|
2975
|
-
for (const line of lines) {
|
|
2976
|
-
const trimmed = line.trim();
|
|
2977
|
-
if (!trimmed) continue;
|
|
2978
|
-
const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
2979
|
-
if (!match) continue;
|
|
2980
|
-
const [, pidS, ppidS, pgidS, cpuS, rssS, etimeS, command] = match;
|
|
2981
|
-
rows.push({
|
|
2982
|
-
pid: parseInt(pidS, 10),
|
|
2983
|
-
ppid: parseInt(ppidS, 10),
|
|
2984
|
-
pgid: parseInt(pgidS, 10),
|
|
2985
|
-
cpuPercent: Math.round(parseFloat(cpuS) * 10) / 10,
|
|
2986
|
-
memoryRssBytes: parseInt(rssS, 10) * 1024,
|
|
2987
|
-
uptimeSec: parseEtime(etimeS),
|
|
2988
|
-
command
|
|
2989
|
-
});
|
|
2990
|
-
}
|
|
2991
|
-
return rows;
|
|
3231
|
+
return await readProcessTable();
|
|
2992
3232
|
} catch (err) {
|
|
2993
|
-
this.ctx.logger.warn("
|
|
3233
|
+
this.ctx.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
2994
3234
|
return [];
|
|
2995
3235
|
}
|
|
2996
3236
|
}
|
|
@@ -3075,28 +3315,5 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
|
|
|
3075
3315
|
}] });
|
|
3076
3316
|
}
|
|
3077
3317
|
};
|
|
3078
|
-
/**
|
|
3079
|
-
* Parse `ps etime` format → seconds.
|
|
3080
|
-
*
|
|
3081
|
-
* Formats we handle:
|
|
3082
|
-
* `MM:SS` (< 1h)
|
|
3083
|
-
* `HH:MM:SS` (< 1d)
|
|
3084
|
-
* `DD-HH:MM:SS` (>= 1d)
|
|
3085
|
-
*/
|
|
3086
|
-
function parseEtime(etime) {
|
|
3087
|
-
const dashParts = etime.split("-");
|
|
3088
|
-
let days = 0;
|
|
3089
|
-
let hms = etime;
|
|
3090
|
-
if (dashParts.length === 2) {
|
|
3091
|
-
days = parseInt(dashParts[0], 10) || 0;
|
|
3092
|
-
hms = dashParts[1];
|
|
3093
|
-
}
|
|
3094
|
-
const parts = hms.split(":").map((p) => parseInt(p, 10) || 0);
|
|
3095
|
-
let hours = 0, minutes = 0, seconds = 0;
|
|
3096
|
-
if (parts.length === 3) [hours, minutes, seconds] = parts;
|
|
3097
|
-
else if (parts.length === 2) [minutes, seconds] = parts;
|
|
3098
|
-
else if (parts.length === 1) [seconds] = parts;
|
|
3099
|
-
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
3100
|
-
}
|
|
3101
3318
|
//#endregion
|
|
3102
|
-
export { NativeMetricsAddon as default,
|
|
3319
|
+
export { NativeMetricsProvider as a, NativeMetricsAddon as default, parseProcStatusRss as i, nodeProcReader as n, parseProcStat as r, lifetimeCpuPercent as t };
|