@fibscope/agent 0.1.0
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/.env.example +7 -0
- package/PUBLISH.md +27 -0
- package/README.md +123 -0
- package/index.mjs +3244 -0
- package/lib/bridge/recommendation.mjs +311 -0
- package/lib/fnn-discovery.mjs +293 -0
- package/lib/observe/collector.mjs +1039 -0
- package/lib/pulse/engine.mjs +21 -0
- package/lib/pulse/index.mjs +6 -0
- package/lib/pulse/intelligence.mjs +154 -0
- package/lib/pulse/providers.mjs +321 -0
- package/lib/pulse/server.mjs +54 -0
- package/lib/pulse/snapshot.mjs +155 -0
- package/lib/route/route.mjs +1053 -0
- package/package.json +29 -0
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export class FiberObservabilityError extends Error {
|
|
8
|
+
constructor(message, code = "FIBER_OBSERVABILITY_ERROR", details = undefined) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "FiberObservabilityError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TelemetryCollector extends EventEmitter {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
super();
|
|
19
|
+
this.options = {
|
|
20
|
+
intervalMs: options.intervalMs ?? 10000,
|
|
21
|
+
timeoutMs: options.timeoutMs ?? 8000,
|
|
22
|
+
maxSnapshots: options.maxSnapshots ?? 100,
|
|
23
|
+
maxEvents: options.maxEvents ?? options.maxLog ?? 1000,
|
|
24
|
+
maxReliabilityEntries: options.maxReliabilityEntries ?? options.maxReliabilityLog ?? options.maxMetricsLog ?? options.maxLog ?? 1000,
|
|
25
|
+
maxAlerts: options.maxAlerts ?? options.maxAlertLog ?? options.maxLog ?? 1000,
|
|
26
|
+
...options,
|
|
27
|
+
};
|
|
28
|
+
this.snapshots = [];
|
|
29
|
+
this.events = [];
|
|
30
|
+
this.reliabilityEntries = [];
|
|
31
|
+
this.alerts = [];
|
|
32
|
+
this.timer = undefined;
|
|
33
|
+
this.running = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async collect() {
|
|
37
|
+
const timestamp = Date.now();
|
|
38
|
+
try {
|
|
39
|
+
const state = await collectFiberState(this.options);
|
|
40
|
+
const payments = await collectPaymentSummaries(this.options, state);
|
|
41
|
+
const snapshot = normalizeTelemetrySnapshot({ timestamp, ...state, payments });
|
|
42
|
+
this.record(snapshot);
|
|
43
|
+
this.emit("snapshot", snapshot);
|
|
44
|
+
return snapshot;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const snapshot = offlineSnapshot(timestamp, error);
|
|
47
|
+
this.record(snapshot);
|
|
48
|
+
this.emit("snapshot", snapshot);
|
|
49
|
+
this.emit("collector-error", error, snapshot);
|
|
50
|
+
return snapshot;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start() {
|
|
55
|
+
if (this.running) return this;
|
|
56
|
+
this.running = true;
|
|
57
|
+
this.collect();
|
|
58
|
+
this.timer = setInterval(() => {
|
|
59
|
+
this.collect();
|
|
60
|
+
}, this.options.intervalMs);
|
|
61
|
+
this.emit("started", { intervalMs: this.options.intervalMs });
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stop() {
|
|
66
|
+
if (this.timer) clearInterval(this.timer);
|
|
67
|
+
this.timer = undefined;
|
|
68
|
+
this.running = false;
|
|
69
|
+
this.emit("stopped");
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
record(snapshot) {
|
|
74
|
+
const previous = this.latest();
|
|
75
|
+
this.snapshots.push(snapshot);
|
|
76
|
+
while (this.snapshots.length > this.options.maxSnapshots) {
|
|
77
|
+
this.snapshots.shift();
|
|
78
|
+
}
|
|
79
|
+
const events = deriveFiberEvents(previous, snapshot);
|
|
80
|
+
for (const event of events) {
|
|
81
|
+
this.events.push(event);
|
|
82
|
+
this.emit("event", event);
|
|
83
|
+
}
|
|
84
|
+
while (this.events.length > this.options.maxEvents) {
|
|
85
|
+
this.events.shift();
|
|
86
|
+
}
|
|
87
|
+
const entries = deriveReliabilityEntries(previous, snapshot, events);
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
this.reliabilityEntries.push(entry);
|
|
90
|
+
this.emit("reliability-entry", entry);
|
|
91
|
+
}
|
|
92
|
+
while (this.reliabilityEntries.length > this.options.maxReliabilityEntries) {
|
|
93
|
+
this.reliabilityEntries.shift();
|
|
94
|
+
}
|
|
95
|
+
const alerts = generateAlerts(snapshot, events, this.health(), this.diagnoses(), this.reliability());
|
|
96
|
+
for (const alert of alerts) {
|
|
97
|
+
if (this.alerts.some((existing) => alertKey(existing) === alertKey(alert))) continue;
|
|
98
|
+
this.alerts.push(alert);
|
|
99
|
+
this.emit("alert", alert);
|
|
100
|
+
}
|
|
101
|
+
while (this.alerts.length > this.options.maxAlerts) {
|
|
102
|
+
this.alerts.shift();
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
latest() {
|
|
108
|
+
return this.snapshots.at(-1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
history() {
|
|
112
|
+
return [...this.snapshots];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
eventLog() {
|
|
116
|
+
return [...this.events];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
health() {
|
|
120
|
+
return analyzeHealth(this.latest(), this.eventLog());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
diagnoses() {
|
|
124
|
+
return analyzeRootCauses(this.latest(), this.eventLog(), this.reliabilityLog());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
reliabilityLog() {
|
|
128
|
+
return [...this.reliabilityEntries];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
reliability() {
|
|
132
|
+
return analyzeReliability(this.reliabilityLog(), this.eventLog());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
alertLog() {
|
|
136
|
+
return [...this.alerts];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
replay(timestamp) {
|
|
140
|
+
return replayHistory(timestamp, this.history(), this.eventLog(), this.reliabilityLog());
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createTelemetryCollector(options = {}) {
|
|
145
|
+
return new TelemetryCollector(options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function collectTelemetrySnapshot(options = {}) {
|
|
149
|
+
const collector = createTelemetryCollector({ ...options, maxSnapshots: 1 });
|
|
150
|
+
return collector.collect();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function deriveFiberEvents(previous, current) {
|
|
154
|
+
if (!current) return [];
|
|
155
|
+
const events = [];
|
|
156
|
+
if (!previous) {
|
|
157
|
+
events.push(fiberEvent(current.timestamp, "node", current.node.online ? "NODE_ONLINE" : "NODE_OFFLINE", current.node));
|
|
158
|
+
for (const peer of current.peers) {
|
|
159
|
+
events.push(fiberEvent(current.timestamp, "peer", peer.connected ? "PEER_CONNECTED" : "PEER_DISCONNECTED", peer));
|
|
160
|
+
}
|
|
161
|
+
for (const channel of current.channels) {
|
|
162
|
+
events.push(fiberEvent(current.timestamp, "channel", channel.status === "open" ? "CHANNEL_OPENED" : "CHANNEL_SEEN", channel));
|
|
163
|
+
}
|
|
164
|
+
for (const payment of current.payments) {
|
|
165
|
+
events.push(fiberEvent(payment.timestamp ?? current.timestamp, "payment", paymentEventType(payment), payment));
|
|
166
|
+
}
|
|
167
|
+
return events;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (previous.node.online !== current.node.online) {
|
|
171
|
+
events.push(fiberEvent(current.timestamp, "node", current.node.online ? "NODE_ONLINE" : "NODE_OFFLINE", current.node));
|
|
172
|
+
}
|
|
173
|
+
if (previous.node.peerCount !== current.node.peerCount) {
|
|
174
|
+
events.push(fiberEvent(current.timestamp, "node", "PEER_COUNT_CHANGED", {
|
|
175
|
+
before: previous.node.peerCount,
|
|
176
|
+
after: current.node.peerCount,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
if (previous.node.channelCount !== current.node.channelCount) {
|
|
180
|
+
events.push(fiberEvent(current.timestamp, "node", "CHANNEL_COUNT_CHANGED", {
|
|
181
|
+
before: previous.node.channelCount,
|
|
182
|
+
after: current.node.channelCount,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
diffById(previous.peers, current.peers, "peerId", {
|
|
187
|
+
added: (peer) => events.push(fiberEvent(current.timestamp, "peer", peer.connected ? "PEER_CONNECTED" : "PEER_SEEN", peer)),
|
|
188
|
+
removed: (peer) => events.push(fiberEvent(current.timestamp, "peer", "PEER_REMOVED", peer)),
|
|
189
|
+
changed: (before, after) => {
|
|
190
|
+
if (before.connected !== after.connected) {
|
|
191
|
+
events.push(fiberEvent(current.timestamp, "peer", after.connected ? "PEER_CONNECTED" : "PEER_DISCONNECTED", { before, after }));
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
diffById(previous.channels, current.channels, "channelId", {
|
|
197
|
+
added: (channel) => events.push(fiberEvent(current.timestamp, "channel", channel.status === "open" ? "CHANNEL_OPENED" : "CHANNEL_SEEN", channel)),
|
|
198
|
+
removed: (channel) => events.push(fiberEvent(current.timestamp, "channel", "CHANNEL_REMOVED", channel)),
|
|
199
|
+
changed: (before, after) => {
|
|
200
|
+
if (before.status !== after.status) {
|
|
201
|
+
events.push(fiberEvent(current.timestamp, "channel", channelStatusEvent(after.status), { before, after }));
|
|
202
|
+
}
|
|
203
|
+
if (before.localBalance !== after.localBalance || before.remoteBalance !== after.remoteBalance) {
|
|
204
|
+
events.push(fiberEvent(current.timestamp, "channel", "CHANNEL_LIQUIDITY_CHANGED", { before, after }));
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
diffById(previous.payments, current.payments, "paymentId", {
|
|
210
|
+
added: (payment) => events.push(fiberEvent(payment.timestamp ?? current.timestamp, "payment", paymentEventType(payment), payment)),
|
|
211
|
+
changed: (before, after) => {
|
|
212
|
+
if (before.status !== after.status) {
|
|
213
|
+
events.push(fiberEvent(after.timestamp ?? current.timestamp, "payment", paymentEventType(after), { before, after }));
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return events;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function analyzeHealth(snapshot, events = []) {
|
|
222
|
+
if (!snapshot) {
|
|
223
|
+
return {
|
|
224
|
+
node: healthScore(0, "critical"),
|
|
225
|
+
peers: healthScore(0, "critical"),
|
|
226
|
+
channels: healthScore(0, "critical"),
|
|
227
|
+
overall: healthScore(0, "critical"),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const node = analyzeNodeHealth(snapshot, events);
|
|
231
|
+
const peers = analyzePeerHealth(snapshot, events);
|
|
232
|
+
const channels = analyzeChannelHealth(snapshot, events);
|
|
233
|
+
const overallScore = Math.round((node.score * 0.4) + (peers.score * 0.25) + (channels.score * 0.35));
|
|
234
|
+
return {
|
|
235
|
+
node,
|
|
236
|
+
peers,
|
|
237
|
+
channels,
|
|
238
|
+
overall: healthScore(overallScore),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function analyzeRootCauses(snapshot, events = [], reliabilityEntries = []) {
|
|
243
|
+
const diagnoses = [];
|
|
244
|
+
if (!snapshot) {
|
|
245
|
+
diagnoses.push(rootCause("UNKNOWN", "No telemetry snapshot is available.", 0.5, [], "Collect telemetry before diagnosing node health."));
|
|
246
|
+
return diagnoses;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!snapshot.node.online) {
|
|
250
|
+
diagnoses.push(rootCause(
|
|
251
|
+
"TIMEOUT",
|
|
252
|
+
"The Fiber node is offline or unreachable.",
|
|
253
|
+
0.95,
|
|
254
|
+
evidenceFromSnapshot(snapshot, "node"),
|
|
255
|
+
"Verify FNN is running, reachable, and listening on the configured RPC endpoint."
|
|
256
|
+
));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const peer of snapshot.peers.filter((item) => !item.connected)) {
|
|
260
|
+
diagnoses.push(rootCause(
|
|
261
|
+
"PEER_OFFLINE",
|
|
262
|
+
`Peer ${short(peer.peerId)} is disconnected.`,
|
|
263
|
+
0.9,
|
|
264
|
+
[{ category: "peer", type: "PEER_DISCONNECTED", payload: peer }],
|
|
265
|
+
"Reconnect the peer or route around it."
|
|
266
|
+
));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const channel of snapshot.channels.filter((item) => item.status !== "open")) {
|
|
270
|
+
diagnoses.push(rootCause(
|
|
271
|
+
"CHANNEL_CLOSED",
|
|
272
|
+
`Channel ${short(channel.channelId)} is ${channel.status}.`,
|
|
273
|
+
0.92,
|
|
274
|
+
[{ category: "channel", type: channelStatusEvent(channel.status), payload: channel }],
|
|
275
|
+
"Wait for the channel to open, reopen it, or select a different route."
|
|
276
|
+
));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const channel of snapshot.channels.filter((item) => item.status === "open" && item.capacity > 0n && (item.localBalance * 100n / item.capacity) < 10n)) {
|
|
280
|
+
diagnoses.push(rootCause(
|
|
281
|
+
"INSUFFICIENT_LIQUIDITY",
|
|
282
|
+
`Channel ${short(channel.channelId)} has low outbound liquidity.`,
|
|
283
|
+
0.86,
|
|
284
|
+
[{ category: "channel", type: "LOW_LOCAL_LIQUIDITY", payload: channel }],
|
|
285
|
+
"Rebalance the channel, add outbound liquidity, or route payments through another channel."
|
|
286
|
+
));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (snapshot.node.online && snapshot.channels.length === 0) {
|
|
290
|
+
diagnoses.push(rootCause(
|
|
291
|
+
"NO_ROUTE",
|
|
292
|
+
"The node has no known channels, so it cannot route payments.",
|
|
293
|
+
0.88,
|
|
294
|
+
evidenceFromSnapshot(snapshot, "channel"),
|
|
295
|
+
"Open a channel or connect to peers with usable route graph data."
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const failedPayments = reliabilityEntries.filter((entry) => entry.status === "failed");
|
|
300
|
+
for (const entry of failedPayments.slice(-5)) {
|
|
301
|
+
diagnoses.push(diagnosisForFailedPayment(entry, snapshot, events));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const highFeeEntries = reliabilityEntries.filter((entry) => entry.fee > 0n && entry.amount > 0n && (entry.fee * 100n / entry.amount) >= 5n);
|
|
305
|
+
if (highFeeEntries.length > 0) {
|
|
306
|
+
diagnoses.push(rootCause(
|
|
307
|
+
"HIGH_ROUTING_FEES",
|
|
308
|
+
"Recent payment attempts have high fees relative to amount.",
|
|
309
|
+
0.72,
|
|
310
|
+
highFeeEntries.slice(-3).map((entry) => ({ category: "payment", type: "HIGH_FEE", payload: entry })),
|
|
311
|
+
"Try alternate routes, smaller route length, or peers with lower advertised fees."
|
|
312
|
+
));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return dedupeRootCauses(diagnoses);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function analyzeReliability(entries = [], events = []) {
|
|
319
|
+
const paymentEntries = entries.filter((entry) => entry.category === "payment");
|
|
320
|
+
const attempted = paymentEntries.length || countRecent(events, "payment", "PAYMENT_STARTED");
|
|
321
|
+
const succeeded = paymentEntries.filter((entry) => entry.status === "succeeded").length || countRecent(events, "payment", "PAYMENT_SUCCEEDED");
|
|
322
|
+
const failed = paymentEntries.filter((entry) => entry.status === "failed").length || countRecent(events, "payment", "PAYMENT_FAILED");
|
|
323
|
+
const completed = succeeded + failed;
|
|
324
|
+
const feeSamples = paymentEntries.filter((entry) => entry.fee !== undefined);
|
|
325
|
+
const latencySamples = paymentEntries.filter((entry) => entry.latencyMs !== undefined);
|
|
326
|
+
const routeSamples = paymentEntries.filter((entry) => entry.routeLength !== undefined);
|
|
327
|
+
return {
|
|
328
|
+
paymentsAttempted: attempted,
|
|
329
|
+
paymentsSucceeded: succeeded,
|
|
330
|
+
paymentsFailed: failed,
|
|
331
|
+
successRate: completed === 0 ? 0 : succeeded / completed,
|
|
332
|
+
failureRate: completed === 0 ? 0 : failed / completed,
|
|
333
|
+
averageFee: averageBigIntAsNumber(feeSamples.map((entry) => entry.fee)),
|
|
334
|
+
averageLatency: averageNumber(latencySamples.map((entry) => entry.latencyMs)),
|
|
335
|
+
averageRouteLength: averageNumber(routeSamples.map((entry) => entry.routeLength)),
|
|
336
|
+
failureFrequency: attempted === 0 ? 0 : failed / attempted,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function generateAlerts(snapshot, events = [], health = analyzeHealth(snapshot, events), diagnoses = [], reliability = analyzeReliability([], events)) {
|
|
341
|
+
const alerts = [];
|
|
342
|
+
if (!snapshot) {
|
|
343
|
+
alerts.push(alert("critical", "No telemetry snapshot", "No Fiber telemetry is available for alert analysis.", "Start the telemetry collector and verify the configured state source."));
|
|
344
|
+
return alerts;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!snapshot.node.online) {
|
|
348
|
+
alerts.push(alert("critical", "Node offline", "The Fiber node is offline or unreachable.", "Verify FNN is running and the RPC endpoint is reachable."));
|
|
349
|
+
}
|
|
350
|
+
const connectedPeers = snapshot.peers.filter((peer) => peer.connected).length;
|
|
351
|
+
if (connectedPeers === 0) {
|
|
352
|
+
alerts.push(alert("critical", "No peers connected", "The node currently has no connected Fiber peers.", "Connect to a Fiber peer or inspect network connectivity."));
|
|
353
|
+
}
|
|
354
|
+
for (const channel of snapshot.channels) {
|
|
355
|
+
if (channel.status !== "open") {
|
|
356
|
+
alerts.push(alert("critical", "Channel not open", `Channel ${short(channel.channelId)} is ${channel.status}.`, "Wait for the channel to open or route around it."));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (channel.capacity > 0n) {
|
|
360
|
+
const outboundPercent = Number((channel.localBalance * 10000n) / channel.capacity) / 100;
|
|
361
|
+
if (outboundPercent < 15) {
|
|
362
|
+
alerts.push(alert("warning", "Outbound liquidity below 15%", `Channel ${short(channel.channelId)} has ${outboundPercent.toFixed(2)}% outbound liquidity.`, "Rebalance the channel or add outbound liquidity."));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const event of events) {
|
|
367
|
+
if (event.type === "PEER_CONNECTED") {
|
|
368
|
+
alerts.push(alert("info", "New peer connected", `Peer ${short(event.payload?.peerId ?? event.payload?.after?.peerId)} connected.`, "No action required."));
|
|
369
|
+
} else if (event.type === "PEER_DISCONNECTED") {
|
|
370
|
+
alerts.push(alert("warning", "Peer disconnected", `Peer ${short(event.payload?.peerId ?? event.payload?.after?.peerId)} disconnected.`, "Monitor peer stability or reconnect manually if needed."));
|
|
371
|
+
} else if (event.type === "PAYMENT_FAILED") {
|
|
372
|
+
alerts.push(alert("warning", "Payment failed", "A payment failure was observed in the telemetry stream.", "Inspect root-cause diagnoses and route/liquidity history around the failure."));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (health.overall?.status === "critical") {
|
|
376
|
+
alerts.push(alert("critical", "Node health critical", `Overall health score is ${health.overall.score}.`, "Inspect node, peer, and channel health factors immediately."));
|
|
377
|
+
} else if (health.overall?.status === "warning") {
|
|
378
|
+
alerts.push(alert("warning", "Node health degraded", `Overall health score is ${health.overall.score}.`, "Review health factors and recent events."));
|
|
379
|
+
}
|
|
380
|
+
for (const diagnosis of diagnoses.slice(0, 3)) {
|
|
381
|
+
const severity = diagnosis.confidence >= 0.9 ? "critical" : "warning";
|
|
382
|
+
alerts.push(alert(severity, `Root cause: ${diagnosis.code}`, diagnosis.explanation, diagnosis.suggestedAction));
|
|
383
|
+
}
|
|
384
|
+
if (reliability.paymentsAttempted > 0 && reliability.failureRate >= 0.5) {
|
|
385
|
+
alerts.push(alert("warning", "High payment failure rate", `Payment failure rate is ${(reliability.failureRate * 100).toFixed(1)}%.`, "Inspect failed payment diagnoses and route around unstable peers/channels."));
|
|
386
|
+
}
|
|
387
|
+
return dedupeAlerts(alerts);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function replayHistory(timestamp, snapshots = [], events = [], reliabilityEntries = []) {
|
|
391
|
+
const point = Number(timestamp);
|
|
392
|
+
const snapshot = latestAtOrBefore(snapshots, point);
|
|
393
|
+
const replayEvents = events.filter((event) => event.timestamp <= point);
|
|
394
|
+
const replayReliability = reliabilityEntries.filter((entry) => entry.timestamp <= point);
|
|
395
|
+
const health = analyzeHealth(snapshot, replayEvents);
|
|
396
|
+
const reliability = analyzeReliability(replayReliability, replayEvents);
|
|
397
|
+
const diagnoses = analyzeRootCauses(snapshot, replayEvents, replayReliability);
|
|
398
|
+
return {
|
|
399
|
+
timestamp: point,
|
|
400
|
+
snapshot,
|
|
401
|
+
counts: {
|
|
402
|
+
payments: replayReliability.length,
|
|
403
|
+
failures: reliability.paymentsFailed,
|
|
404
|
+
peers: snapshot?.peers.length ?? 0,
|
|
405
|
+
connectedPeers: snapshot?.peers.filter((peer) => peer.connected).length ?? 0,
|
|
406
|
+
channels: snapshot?.channels.length ?? 0,
|
|
407
|
+
openChannels: snapshot?.channels.filter((channel) => channel.status === "open").length ?? 0,
|
|
408
|
+
},
|
|
409
|
+
health,
|
|
410
|
+
reliability,
|
|
411
|
+
diagnoses,
|
|
412
|
+
events: replayEvents,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function normalizeTelemetrySnapshot(input = {}) {
|
|
417
|
+
return {
|
|
418
|
+
timestamp: numberOrNow(input.timestamp),
|
|
419
|
+
node: normalizeNode(input.node),
|
|
420
|
+
peers: (input.peers ?? []).map(normalizePeer),
|
|
421
|
+
channels: (input.channels ?? []).map(normalizeChannel),
|
|
422
|
+
payments: (input.payments ?? []).map(normalizePaymentSummary),
|
|
423
|
+
diagnostics: (input.diagnostics ?? []).map(normalizeDiagnostic),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function collectFiberState(options) {
|
|
428
|
+
if (options.stateProvider) {
|
|
429
|
+
return options.stateProvider();
|
|
430
|
+
}
|
|
431
|
+
const pulseUrl = options.pulseUrl;
|
|
432
|
+
const pulseModule = options.pulseModule;
|
|
433
|
+
if (pulseUrl) {
|
|
434
|
+
return fetchPulseState(pulseUrl, options);
|
|
435
|
+
}
|
|
436
|
+
if (pulseModule) {
|
|
437
|
+
const imported = await import(resolveImportSpecifier(pulseModule));
|
|
438
|
+
if (typeof imported.collectFiberStateReport !== "function") {
|
|
439
|
+
throw new FiberObservabilityError("pulseModule must export collectFiberStateReport().", "INVALID_PULSE_MODULE");
|
|
440
|
+
}
|
|
441
|
+
return imported.collectFiberStateReport({
|
|
442
|
+
rpcUrl: options.rpcUrl,
|
|
443
|
+
timeoutMs: options.timeoutMs,
|
|
444
|
+
rpcHeaders: options.rpcHeaders,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
if (options.rpcUrl) {
|
|
448
|
+
const imported = await import(resolveImportSpecifier(options.defaultPulseModule ?? "../pulse/index.mjs"));
|
|
449
|
+
return imported.collectFiberStateReport({
|
|
450
|
+
rpcUrl: options.rpcUrl,
|
|
451
|
+
timeoutMs: options.timeoutMs,
|
|
452
|
+
rpcHeaders: options.rpcHeaders,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
throw new FiberObservabilityError("A state source is required: stateProvider, pulseUrl, pulseModule, or rpcUrl.", "NO_STATE_SOURCE");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function fetchPulseState(pulseUrl, options) {
|
|
459
|
+
const controller = new AbortController();
|
|
460
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
|
|
461
|
+
try {
|
|
462
|
+
const response = await fetch(`${pulseUrl.replace(/\/$/, "")}/state`, { signal: controller.signal });
|
|
463
|
+
if (!response.ok) {
|
|
464
|
+
throw new FiberObservabilityError(`Pulse /state failed with HTTP ${response.status}.`, "PULSE_STATE_FAILED");
|
|
465
|
+
}
|
|
466
|
+
return response.json();
|
|
467
|
+
} finally {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function collectPaymentSummaries(options, state) {
|
|
473
|
+
if (typeof options.paymentProvider === "function") {
|
|
474
|
+
const payments = await options.paymentProvider(state);
|
|
475
|
+
return payments ?? [];
|
|
476
|
+
}
|
|
477
|
+
if (Array.isArray(state.payments)) {
|
|
478
|
+
return state.payments;
|
|
479
|
+
}
|
|
480
|
+
if (options.rpcUrl) {
|
|
481
|
+
return collectPaymentSummariesFromRpc(options);
|
|
482
|
+
}
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function collectPaymentSummariesFromRpc(options) {
|
|
487
|
+
const candidates = [
|
|
488
|
+
["list_payments", [{}]],
|
|
489
|
+
["get_payment", [{}]],
|
|
490
|
+
];
|
|
491
|
+
for (const [method, params] of candidates) {
|
|
492
|
+
try {
|
|
493
|
+
const result = await rpc(options.rpcUrl, method, params, options);
|
|
494
|
+
const items = extractList(result, "payments");
|
|
495
|
+
if (items.length > 0) return items.map(normalizePaymentSummary);
|
|
496
|
+
} catch {
|
|
497
|
+
// FNN versions differ here; absence of payment APIs should not break telemetry.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function rpc(rpcUrl, method, params = [], options = {}) {
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
|
|
506
|
+
try {
|
|
507
|
+
const response = await fetch(rpcUrl, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: { "content-type": "application/json", ...(options.rpcHeaders ?? {}) },
|
|
510
|
+
body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
|
|
511
|
+
signal: controller.signal,
|
|
512
|
+
});
|
|
513
|
+
const payload = await response.json();
|
|
514
|
+
if (payload.error) throw new FiberObservabilityError(payload.error.message ?? "Fiber RPC error", "FIBER_RPC_ERROR", payload.error);
|
|
515
|
+
return payload.result;
|
|
516
|
+
} finally {
|
|
517
|
+
clearTimeout(timer);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function offlineSnapshot(timestamp, error) {
|
|
522
|
+
return normalizeTelemetrySnapshot({
|
|
523
|
+
timestamp,
|
|
524
|
+
node: {
|
|
525
|
+
nodeId: "unknown",
|
|
526
|
+
version: "unknown",
|
|
527
|
+
online: false,
|
|
528
|
+
peerCount: 0,
|
|
529
|
+
channelCount: 0,
|
|
530
|
+
lastUpdated: timestamp,
|
|
531
|
+
error: error instanceof Error ? error.message : String(error),
|
|
532
|
+
},
|
|
533
|
+
peers: [],
|
|
534
|
+
channels: [],
|
|
535
|
+
payments: [],
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function normalizeNode(node = {}) {
|
|
540
|
+
return {
|
|
541
|
+
nodeId: stringValue(node.nodeId ?? node.node_id ?? node.pubkey, "unknown"),
|
|
542
|
+
version: stringValue(node.version, "unknown"),
|
|
543
|
+
online: Boolean(node.online),
|
|
544
|
+
peerCount: numberValue(node.peerCount ?? node.peer_count, 0),
|
|
545
|
+
channelCount: numberValue(node.channelCount ?? node.channel_count, 0),
|
|
546
|
+
lastUpdated: numberValue(node.lastUpdated ?? node.last_updated, Date.now()),
|
|
547
|
+
error: node.error,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function normalizePeer(peer = {}) {
|
|
552
|
+
return {
|
|
553
|
+
peerId: stringValue(peer.peerId ?? peer.peer_id ?? peer.nodeId ?? peer.pubkey, "unknown"),
|
|
554
|
+
connected: Boolean(peer.connected ?? peer.online),
|
|
555
|
+
address: peer.address,
|
|
556
|
+
lastSeen: peer.lastSeen ?? peer.last_seen,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function normalizeChannel(channel = {}) {
|
|
561
|
+
return {
|
|
562
|
+
channelId: stringValue(channel.channelId ?? channel.channel_id ?? channel.channel_outpoint, "unknown"),
|
|
563
|
+
peerId: stringValue(channel.peerId ?? channel.peer_id ?? channel.counterparty, "unknown"),
|
|
564
|
+
status: stringValue(channel.status ?? channel.state, "unknown"),
|
|
565
|
+
capacity: amountMaybe(channel.capacity) ?? 0n,
|
|
566
|
+
localBalance: amountMaybe(channel.localBalance ?? channel.local_balance) ?? 0n,
|
|
567
|
+
remoteBalance: amountMaybe(channel.remoteBalance ?? channel.remote_balance) ?? 0n,
|
|
568
|
+
assetType: stringValue(channel.assetType ?? channel.asset_type, "CKB"),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function normalizePaymentSummary(payment = {}) {
|
|
573
|
+
return {
|
|
574
|
+
paymentId: stringValue(payment.paymentId ?? payment.payment_id ?? payment.id ?? payment.hash, "unknown"),
|
|
575
|
+
status: stringValue(payment.status ?? payment.state, "unknown"),
|
|
576
|
+
amount: amountMaybe(payment.amount ?? payment.value) ?? 0n,
|
|
577
|
+
assetType: stringValue(payment.assetType ?? payment.asset_type, "CKB"),
|
|
578
|
+
direction: payment.direction,
|
|
579
|
+
route: payment.route ?? payment.hops ?? [],
|
|
580
|
+
fee: amountMaybe(payment.fee ?? payment.routingFee ?? payment.routing_fee ?? payment.totalFee ?? payment.total_fee),
|
|
581
|
+
latencyMs: numberMaybe(payment.latencyMs ?? payment.latency_ms ?? payment.durationMs ?? payment.duration_ms),
|
|
582
|
+
error: payment.error ?? payment.failure_reason,
|
|
583
|
+
timestamp: numberValue(payment.timestamp ?? payment.created_at ?? payment.updated_at, Date.now()),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function normalizeDiagnostic(diagnostic = {}) {
|
|
588
|
+
return {
|
|
589
|
+
code: stringValue(diagnostic.code, "UNKNOWN"),
|
|
590
|
+
severity: stringValue(diagnostic.severity, "info"),
|
|
591
|
+
title: stringValue(diagnostic.title, diagnostic.code ?? "Diagnostic"),
|
|
592
|
+
description: stringValue(diagnostic.description, ""),
|
|
593
|
+
recommendation: diagnostic.recommendation,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function deriveReliabilityEntries(previous, current, events) {
|
|
598
|
+
const entries = [];
|
|
599
|
+
for (const event of events) {
|
|
600
|
+
if (event.category !== "payment") continue;
|
|
601
|
+
const payment = event.payload?.after ?? event.payload;
|
|
602
|
+
entries.push(reliabilityEntryFromPaymentEvent(event, payment));
|
|
603
|
+
}
|
|
604
|
+
if (!previous && current?.payments?.length) {
|
|
605
|
+
for (const payment of current.payments) {
|
|
606
|
+
entries.push(reliabilityEntryFromPaymentEvent(fiberEvent(payment.timestamp ?? current.timestamp, "payment", paymentEventType(payment), payment), payment));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return dedupeReliabilityEntries(entries);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function reliabilityEntryFromPaymentEvent(event, payment = {}) {
|
|
613
|
+
return {
|
|
614
|
+
id: event.id,
|
|
615
|
+
timestamp: event.timestamp,
|
|
616
|
+
category: "payment",
|
|
617
|
+
paymentId: payment.paymentId ?? "unknown",
|
|
618
|
+
status: reliabilityStatus(payment.status ?? event.type),
|
|
619
|
+
amount: amountMaybe(payment.amount) ?? 0n,
|
|
620
|
+
fee: amountMaybe(payment.fee ?? payment.routingFee ?? payment.routing_fee ?? payment.totalFee ?? payment.total_fee) ?? 0n,
|
|
621
|
+
routeLength: Array.isArray(payment.route) ? payment.route.length : undefined,
|
|
622
|
+
latencyMs: numberMaybe(payment.latencyMs ?? payment.latency_ms ?? payment.durationMs ?? payment.duration_ms),
|
|
623
|
+
error: payment.error,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function reliabilityStatus(status) {
|
|
628
|
+
const text = String(status ?? "").toLowerCase();
|
|
629
|
+
if (text.includes("fail") || text.includes("reject")) return "failed";
|
|
630
|
+
if (text.includes("success") || text.includes("sent") || text.includes("complete") || text.includes("succeed")) return "succeeded";
|
|
631
|
+
return "attempted";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function diagnosisForFailedPayment(entry, snapshot, events) {
|
|
635
|
+
const recentPeerOffline = lastEvent(events, "peer", "PEER_DISCONNECTED");
|
|
636
|
+
if (recentPeerOffline) {
|
|
637
|
+
return rootCause(
|
|
638
|
+
"PEER_OFFLINE",
|
|
639
|
+
"A payment failed near a peer disconnect event.",
|
|
640
|
+
0.76,
|
|
641
|
+
[{ category: "payment", type: "PAYMENT_FAILED", payload: entry }, recentPeerOffline],
|
|
642
|
+
"Reconnect the peer or use a route that avoids the unstable peer."
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
const lowLiquidity = snapshot.channels.find((channel) => channel.capacity > 0n && (channel.localBalance * 100n / channel.capacity) < 10n);
|
|
646
|
+
if (lowLiquidity) {
|
|
647
|
+
return rootCause(
|
|
648
|
+
"INSUFFICIENT_LIQUIDITY",
|
|
649
|
+
"A payment failed while at least one channel has low outbound liquidity.",
|
|
650
|
+
0.74,
|
|
651
|
+
[{ category: "payment", type: "PAYMENT_FAILED", payload: entry }, { category: "channel", type: "LOW_LOCAL_LIQUIDITY", payload: lowLiquidity }],
|
|
652
|
+
"Rebalance low-liquidity channels or use alternate routes."
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (!snapshot.node.online) {
|
|
656
|
+
return rootCause(
|
|
657
|
+
"TIMEOUT",
|
|
658
|
+
"A payment failed while the node was offline.",
|
|
659
|
+
0.82,
|
|
660
|
+
[{ category: "payment", type: "PAYMENT_FAILED", payload: entry }, { category: "node", type: "NODE_OFFLINE", payload: snapshot.node }],
|
|
661
|
+
"Stabilize the node and retry after it is online."
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
return rootCause(
|
|
665
|
+
"UNKNOWN",
|
|
666
|
+
"A payment failed, but available telemetry does not identify a more specific cause.",
|
|
667
|
+
0.45,
|
|
668
|
+
[{ category: "payment", type: "PAYMENT_FAILED", payload: entry }],
|
|
669
|
+
"Collect more Fiber RPC payment details, route data, and peer/channel events around the failure."
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function rootCause(code, explanation, confidence, evidence, suggestedAction) {
|
|
674
|
+
const affected = affectedFromEvidence(evidence);
|
|
675
|
+
return {
|
|
676
|
+
code,
|
|
677
|
+
explanation,
|
|
678
|
+
confidence,
|
|
679
|
+
evidence,
|
|
680
|
+
suggestedAction,
|
|
681
|
+
affectedChannel: affected.channel,
|
|
682
|
+
affectedPeer: affected.peer,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function affectedFromEvidence(evidence = []) {
|
|
687
|
+
let channel;
|
|
688
|
+
let peer;
|
|
689
|
+
for (const item of evidence) {
|
|
690
|
+
const payload = item.payload?.after ?? item.payload ?? {};
|
|
691
|
+
channel ??= payload.channelId;
|
|
692
|
+
peer ??= payload.peerId ?? payload.fromNode ?? payload.toNode;
|
|
693
|
+
}
|
|
694
|
+
return { channel, peer };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function evidenceFromSnapshot(snapshot, category) {
|
|
698
|
+
if (category === "node") return [{ category: "node", type: snapshot.node.online ? "NODE_ONLINE" : "NODE_OFFLINE", payload: snapshot.node }];
|
|
699
|
+
if (category === "channel") return snapshot.channels.map((channel) => ({ category: "channel", type: channelStatusEvent(channel.status), payload: channel }));
|
|
700
|
+
if (category === "peer") return snapshot.peers.map((peer) => ({ category: "peer", type: peer.connected ? "PEER_CONNECTED" : "PEER_DISCONNECTED", payload: peer }));
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function dedupeRootCauses(diagnoses) {
|
|
705
|
+
const seen = new Set();
|
|
706
|
+
const deduped = [];
|
|
707
|
+
for (const diagnosis of diagnoses) {
|
|
708
|
+
const key = `${diagnosis.code}:${diagnosis.affectedChannel ?? ""}:${diagnosis.affectedPeer ?? ""}:${diagnosis.explanation}`;
|
|
709
|
+
if (seen.has(key)) continue;
|
|
710
|
+
seen.add(key);
|
|
711
|
+
deduped.push(diagnosis);
|
|
712
|
+
}
|
|
713
|
+
return deduped.sort((left, right) => right.confidence - left.confidence);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function dedupeReliabilityEntries(entries) {
|
|
717
|
+
const seen = new Set();
|
|
718
|
+
return entries.filter((entry) => {
|
|
719
|
+
if (seen.has(entry.id)) return false;
|
|
720
|
+
seen.add(entry.id);
|
|
721
|
+
return true;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function alert(severity, title, description, recommendation) {
|
|
726
|
+
return {
|
|
727
|
+
id: `${Date.now()}:${severity}:${title}`,
|
|
728
|
+
timestamp: Date.now(),
|
|
729
|
+
severity,
|
|
730
|
+
title,
|
|
731
|
+
description,
|
|
732
|
+
recommendation,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function dedupeAlerts(alerts) {
|
|
737
|
+
const seen = new Set();
|
|
738
|
+
return alerts.filter((item) => {
|
|
739
|
+
const key = alertKey(item);
|
|
740
|
+
if (seen.has(key)) return false;
|
|
741
|
+
seen.add(key);
|
|
742
|
+
return true;
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function alertKey(item) {
|
|
747
|
+
return `${item.severity}:${item.title}:${item.description}`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function latestAtOrBefore(items, timestamp) {
|
|
751
|
+
return [...items]
|
|
752
|
+
.filter((item) => item.timestamp <= timestamp)
|
|
753
|
+
.sort((left, right) => right.timestamp - left.timestamp)[0];
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function fiberEvent(timestamp, category, type, payload) {
|
|
757
|
+
return {
|
|
758
|
+
id: `${timestamp}:${category}:${type}:${stableEventKey(payload)}`,
|
|
759
|
+
timestamp,
|
|
760
|
+
category,
|
|
761
|
+
type,
|
|
762
|
+
payload,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function stableEventKey(payload) {
|
|
767
|
+
if (!payload || typeof payload !== "object") return "event";
|
|
768
|
+
return payload.channelId
|
|
769
|
+
?? payload.peerId
|
|
770
|
+
?? payload.paymentId
|
|
771
|
+
?? payload.nodeId
|
|
772
|
+
?? payload.after?.channelId
|
|
773
|
+
?? payload.after?.peerId
|
|
774
|
+
?? payload.after?.paymentId
|
|
775
|
+
?? payload.before?.channelId
|
|
776
|
+
?? payload.before?.peerId
|
|
777
|
+
?? payload.before?.paymentId
|
|
778
|
+
?? "event";
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function diffById(previousItems, currentItems, key, handlers) {
|
|
782
|
+
const previousById = new Map(previousItems.map((item) => [item[key], item]));
|
|
783
|
+
const currentById = new Map(currentItems.map((item) => [item[key], item]));
|
|
784
|
+
for (const [id, item] of currentById) {
|
|
785
|
+
if (!previousById.has(id)) {
|
|
786
|
+
handlers.added?.(item);
|
|
787
|
+
} else {
|
|
788
|
+
handlers.changed?.(previousById.get(id), item);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
for (const [id, item] of previousById) {
|
|
792
|
+
if (!currentById.has(id)) handlers.removed?.(item);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function channelStatusEvent(status) {
|
|
797
|
+
if (status === "open") return "CHANNEL_OPENED";
|
|
798
|
+
if (status === "closed") return "CHANNEL_CLOSED";
|
|
799
|
+
if (status === "closing") return "CHANNEL_CLOSING";
|
|
800
|
+
if (status === "pending") return "CHANNEL_PENDING";
|
|
801
|
+
return "CHANNEL_STATUS_CHANGED";
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function paymentEventType(payment) {
|
|
805
|
+
const status = String(payment.status ?? "unknown").toLowerCase();
|
|
806
|
+
if (status.includes("fail") || status.includes("reject")) return "PAYMENT_FAILED";
|
|
807
|
+
if (status.includes("success") || status.includes("sent") || status.includes("complete")) return "PAYMENT_SUCCEEDED";
|
|
808
|
+
if (status.includes("pending") || status.includes("created") || status.includes("started")) return "PAYMENT_STARTED";
|
|
809
|
+
return "PAYMENT_SEEN";
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function analyzeNodeHealth(snapshot, events) {
|
|
813
|
+
let score = 100;
|
|
814
|
+
const factors = [];
|
|
815
|
+
if (!snapshot.node.online) {
|
|
816
|
+
score -= 70;
|
|
817
|
+
factors.push("Node is offline.");
|
|
818
|
+
}
|
|
819
|
+
if (snapshot.node.peerCount === 0 && snapshot.peers.length === 0) {
|
|
820
|
+
score -= 20;
|
|
821
|
+
factors.push("Node has no connected peers.");
|
|
822
|
+
}
|
|
823
|
+
if (snapshot.node.channelCount === 0 && snapshot.channels.length === 0) {
|
|
824
|
+
score -= 10;
|
|
825
|
+
factors.push("Node has no channels.");
|
|
826
|
+
}
|
|
827
|
+
const offlineEvents = countRecent(events, "node", "NODE_OFFLINE");
|
|
828
|
+
if (offlineEvents > 0) {
|
|
829
|
+
score -= Math.min(20, offlineEvents * 5);
|
|
830
|
+
factors.push(`Recent offline events: ${offlineEvents}.`);
|
|
831
|
+
}
|
|
832
|
+
return healthScore(score, undefined, factors);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function analyzePeerHealth(snapshot, events) {
|
|
836
|
+
let score = 100;
|
|
837
|
+
const factors = [];
|
|
838
|
+
const total = snapshot.peers.length;
|
|
839
|
+
const connected = snapshot.peers.filter((peer) => peer.connected).length;
|
|
840
|
+
if (total === 0) {
|
|
841
|
+
score -= 60;
|
|
842
|
+
factors.push("No peers are known.");
|
|
843
|
+
} else {
|
|
844
|
+
const connectedRatio = connected / total;
|
|
845
|
+
score -= Math.round((1 - connectedRatio) * 50);
|
|
846
|
+
if (connectedRatio < 1) factors.push(`${total - connected} peer(s) disconnected.`);
|
|
847
|
+
}
|
|
848
|
+
const disconnects = countRecent(events, "peer", "PEER_DISCONNECTED");
|
|
849
|
+
if (disconnects > 0) {
|
|
850
|
+
score -= Math.min(30, disconnects * 10);
|
|
851
|
+
factors.push(`Recent peer disconnects: ${disconnects}.`);
|
|
852
|
+
}
|
|
853
|
+
return healthScore(score, undefined, factors);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function analyzeChannelHealth(snapshot, events) {
|
|
857
|
+
let score = 100;
|
|
858
|
+
const factors = [];
|
|
859
|
+
if (snapshot.channels.length === 0) {
|
|
860
|
+
score -= 70;
|
|
861
|
+
factors.push("No channels are known.");
|
|
862
|
+
}
|
|
863
|
+
const nonOpen = snapshot.channels.filter((channel) => channel.status !== "open").length;
|
|
864
|
+
if (nonOpen > 0) {
|
|
865
|
+
score -= Math.min(50, nonOpen * 20);
|
|
866
|
+
factors.push(`${nonOpen} channel(s) are not open.`);
|
|
867
|
+
}
|
|
868
|
+
const lowLiquidity = snapshot.channels.filter((channel) => channel.status === "open" && channel.capacity > 0n && (channel.localBalance * 100n / channel.capacity) < 10n).length;
|
|
869
|
+
if (lowLiquidity > 0) {
|
|
870
|
+
score -= Math.min(30, lowLiquidity * 10);
|
|
871
|
+
factors.push(`${lowLiquidity} channel(s) have low local liquidity.`);
|
|
872
|
+
}
|
|
873
|
+
const failures = countRecent(events, "payment", "PAYMENT_FAILED");
|
|
874
|
+
if (failures > 0) {
|
|
875
|
+
score -= Math.min(25, failures * 8);
|
|
876
|
+
factors.push(`Recent payment failures: ${failures}.`);
|
|
877
|
+
}
|
|
878
|
+
return healthScore(score, undefined, factors);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function healthScore(score, status = undefined, factors = []) {
|
|
882
|
+
const normalized = clamp(Math.round(score), 0, 100);
|
|
883
|
+
return {
|
|
884
|
+
score: normalized,
|
|
885
|
+
status: status ?? statusForScore(normalized),
|
|
886
|
+
factors,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function statusForScore(score) {
|
|
891
|
+
if (score >= 80) return "healthy";
|
|
892
|
+
if (score >= 50) return "warning";
|
|
893
|
+
return "critical";
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function countRecent(events, category, type) {
|
|
897
|
+
return events.filter((event) => event.category === category && event.type === type).length;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function lastEvent(events, category, type) {
|
|
901
|
+
return [...events].reverse().find((event) => event.category === category && event.type === type);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function averageBigIntAsNumber(values) {
|
|
905
|
+
if (values.length === 0) return 0;
|
|
906
|
+
return Number(values.reduce((sum, value) => sum + value, 0n)) / values.length;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function averageNumber(values) {
|
|
910
|
+
if (values.length === 0) return 0;
|
|
911
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function clamp(value, min, max) {
|
|
915
|
+
return Math.min(max, Math.max(min, value));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function extractList(value, key) {
|
|
919
|
+
if (Array.isArray(value)) return value;
|
|
920
|
+
if (Array.isArray(value?.[key])) return value[key];
|
|
921
|
+
return [];
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function amountMaybe(value) {
|
|
925
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
926
|
+
if (typeof value === "bigint") return value;
|
|
927
|
+
if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value));
|
|
928
|
+
if (typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value);
|
|
929
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function stringValue(value, fallback) {
|
|
934
|
+
return value === undefined || value === null || value === "" ? fallback : String(value);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function short(value) {
|
|
938
|
+
const text = String(value ?? "");
|
|
939
|
+
return text.length <= 14 ? text : `${text.slice(0, 8)}...${text.slice(-6)}`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function numberValue(value, fallback) {
|
|
943
|
+
const parsed = Number(value);
|
|
944
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function numberMaybe(value) {
|
|
948
|
+
const parsed = Number(value);
|
|
949
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function numberOrNow(value) {
|
|
953
|
+
return numberValue(value, Date.now());
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function resolveImportSpecifier(specifier) {
|
|
957
|
+
if (/^(file|data|node|https?):/.test(specifier)) return specifier;
|
|
958
|
+
if (specifier.startsWith(".") || specifier.includes("\\") || specifier.includes("/")) {
|
|
959
|
+
return pathToFileURL(path.resolve(process.cwd(), specifier)).href;
|
|
960
|
+
}
|
|
961
|
+
return specifier;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function stringify(value) {
|
|
965
|
+
return JSON.stringify(value, (_, nested) => typeof nested === "bigint" ? nested.toString() : nested, 2);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function parseCliArgs(argv) {
|
|
969
|
+
const options = {};
|
|
970
|
+
let command = argv[0]?.startsWith("--") ? "collect" : (argv.shift() ?? "help");
|
|
971
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
972
|
+
const raw = argv[index];
|
|
973
|
+
const [flag, inline] = raw.split("=", 2);
|
|
974
|
+
const value = () => inline ?? argv[++index];
|
|
975
|
+
if (flag === "--rpc") options.rpcUrl = value();
|
|
976
|
+
else if (flag === "--pulse-url") options.pulseUrl = value();
|
|
977
|
+
else if (flag === "--pulse-module") options.pulseModule = value();
|
|
978
|
+
else if (flag === "--interval") options.intervalMs = Number(value());
|
|
979
|
+
else if (flag === "--samples") options.samples = Number(value());
|
|
980
|
+
else if (flag === "--max-snapshots") options.maxSnapshots = Number(value());
|
|
981
|
+
else if (flag === "--max-events" || flag === "--max-log") options.maxEvents = Number(value());
|
|
982
|
+
else if (flag === "--max-reliability-entries" || flag === "--max-reliability-log" || flag === "--max-metrics-log") options.maxReliabilityEntries = Number(value());
|
|
983
|
+
else if (flag === "--max-alerts" || flag === "--max-alert-log") options.maxAlerts = Number(value());
|
|
984
|
+
else if (flag === "--json") options.json = true;
|
|
985
|
+
else if (flag === "--help" || flag === "-h") command = "help";
|
|
986
|
+
else throw new FiberObservabilityError(`Unknown option ${flag}`, "CLI_USAGE_ERROR");
|
|
987
|
+
}
|
|
988
|
+
return { command, options };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function help() {
|
|
992
|
+
return [
|
|
993
|
+
"Fiber Observability Telemetry Collector",
|
|
994
|
+
"",
|
|
995
|
+
"Usage:",
|
|
996
|
+
" node collector.mjs collect --rpc http://127.0.0.1:8227 --pulse-module ../pulse/index.mjs --json",
|
|
997
|
+
" node collector.mjs watch --rpc http://127.0.0.1:8227 --pulse-module ../pulse/index.mjs --interval 5000 --samples 3 --max-events 1000 --max-reliability-log 1000 --max-alerts 1000 --json",
|
|
998
|
+
].join("\n");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1002
|
+
const { command, options } = parseCliArgs(process.argv.slice(2));
|
|
1003
|
+
if (command === "help") {
|
|
1004
|
+
console.log(help());
|
|
1005
|
+
} else if (command === "collect") {
|
|
1006
|
+
collectTelemetrySnapshot(options)
|
|
1007
|
+
.then((snapshot) => console.log(options.json ? stringify(snapshot) : summarizeSnapshot(snapshot)))
|
|
1008
|
+
.catch((error) => {
|
|
1009
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1010
|
+
process.exitCode = 1;
|
|
1011
|
+
});
|
|
1012
|
+
} else if (command === "watch") {
|
|
1013
|
+
const target = options.samples ?? 0;
|
|
1014
|
+
let count = 0;
|
|
1015
|
+
const collector = createTelemetryCollector(options);
|
|
1016
|
+
collector.on("snapshot", (snapshot) => {
|
|
1017
|
+
count += 1;
|
|
1018
|
+
console.log(options.json ? stringify(snapshot) : summarizeSnapshot(snapshot));
|
|
1019
|
+
if (target > 0 && count >= target) {
|
|
1020
|
+
collector.stop();
|
|
1021
|
+
process.exitCode = 0;
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
collector.start();
|
|
1025
|
+
} else {
|
|
1026
|
+
console.error(`Unknown command ${command}`);
|
|
1027
|
+
process.exitCode = 1;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function summarizeSnapshot(snapshot) {
|
|
1032
|
+
return [
|
|
1033
|
+
`timestamp=${snapshot.timestamp}`,
|
|
1034
|
+
`online=${snapshot.node.online}`,
|
|
1035
|
+
`peers=${snapshot.peers.length}`,
|
|
1036
|
+
`channels=${snapshot.channels.length}`,
|
|
1037
|
+
`payments=${snapshot.payments.length}`,
|
|
1038
|
+
].join(" ");
|
|
1039
|
+
}
|