@hyperwatch/hyperwatch 4.2.0 → 5.0.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/README.md +9 -4
- package/config/apache_syslog_example.js +29 -0
- package/config/default.js +2 -2
- package/config/example.js +5 -5
- package/config/express_websocket_example.js +28 -0
- package/config/websocket_client_example.js +52 -0
- package/docs/configuration.md +67 -6
- package/docs/express-embedding.md +137 -0
- package/docs/input.md +50 -24
- package/docs/tutorials/apache_input.md +16 -14
- package/docs/tutorials/express_input.md +10 -8
- package/package.json +42 -35
- package/scripts/fetch-anthropic-ips.js +60 -0
- package/scripts/fetch-cloudflare-ips.js +27 -0
- package/scripts/fetch-cloudfront-ips.js +29 -0
- package/scripts/fetch-openai-ips.js +34 -0
- package/src/app/api.js +149 -8
- package/src/app/index.js +8 -4
- package/src/app/mount.js +115 -0
- package/src/app/websocket.js +39 -10
- package/src/app/ws-server.js +123 -0
- package/src/constants.js +17 -5
- package/src/data/amazon-searchbot-ips.json +818 -0
- package/src/data/amazon-user-ips.json +1025 -0
- package/src/data/amazonbot-ips.json +1294 -0
- package/src/data/chatgpt-user-ips.json +231 -0
- package/src/data/claude-bot-ips.json +28 -0
- package/src/data/cloudflare-ips.json +24 -0
- package/src/data/cloudfront-ips.json +245 -0
- package/src/data/gptbot-ips.json +20 -0
- package/src/data/openai-searchbot-ips.json +41 -0
- package/src/index.js +24 -3
- package/src/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +35 -19
- package/src/lib/aggregator.js +169 -20
- package/src/lib/formatter.js +10 -1
- package/src/lib/index.js +2 -0
- package/src/lib/log-buffer.js +45 -0
- package/src/lib/persistence.js +82 -0
- package/src/lib/pipeline.js +122 -11
- package/src/lib/recent-map.js +23 -0
- package/src/lib/speed.js +43 -3
- package/src/lib/util.js +17 -1
- package/src/modules/address.js +59 -2
- package/src/modules/agent.js +1 -1
- package/src/modules/dnsbl.js +11 -1
- package/src/modules/history.js +62 -0
- package/src/modules/identity.js +154 -26
- package/src/modules/index.js +16 -5
- package/src/modules/language.js +1 -1
- package/src/modules/signature.js +64 -18
- package/src/modules/sparkline.js +7 -3
- package/src/modules/status.js +13 -8
- package/src/plugins/proxy.js +10 -203
- package/src/script.js +0 -1
- package/scripts/cloudfront-ips.js +0 -56
package/src/input/websocket.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { fromJS } = require('immutable');
|
|
2
2
|
const WebSocket = require('ws');
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const wsServer = require('../app/ws-server');
|
|
5
5
|
|
|
6
6
|
const defaultParse = (s) => fromJS(JSON.parse(s));
|
|
7
7
|
|
|
@@ -16,11 +16,15 @@ function create({
|
|
|
16
16
|
parse = defaultParse,
|
|
17
17
|
sample = 1,
|
|
18
18
|
reconnectOnClose = false,
|
|
19
|
+
heartbeatInterval = 30000,
|
|
19
20
|
}) {
|
|
20
21
|
let client;
|
|
21
22
|
|
|
23
|
+
let reconnectAttempts = 0;
|
|
24
|
+
|
|
22
25
|
const setupWebSocketClient = ({ status, success, reject }) => {
|
|
23
26
|
let isAlive;
|
|
27
|
+
let keepAlive;
|
|
24
28
|
|
|
25
29
|
if (username && password) {
|
|
26
30
|
options.headers = options.headers || {};
|
|
@@ -34,7 +38,23 @@ function create({
|
|
|
34
38
|
|
|
35
39
|
client.on('open', () => {
|
|
36
40
|
isAlive = true;
|
|
41
|
+
reconnectAttempts = 0;
|
|
37
42
|
status(null, `Listening to ${address}`);
|
|
43
|
+
|
|
44
|
+
// Heartbeat: detect stale connections
|
|
45
|
+
keepAlive = setInterval(() => {
|
|
46
|
+
if (isAlive === false) {
|
|
47
|
+
client.terminate();
|
|
48
|
+
clearInterval(keepAlive);
|
|
49
|
+
} else {
|
|
50
|
+
try {
|
|
51
|
+
client.ping();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
status(err, 'Websocket error');
|
|
54
|
+
}
|
|
55
|
+
isAlive = false;
|
|
56
|
+
}
|
|
57
|
+
}, heartbeatInterval);
|
|
38
58
|
});
|
|
39
59
|
|
|
40
60
|
client.on('message', (message) => {
|
|
@@ -52,38 +72,34 @@ function create({
|
|
|
52
72
|
status(err, 'Websocket error');
|
|
53
73
|
});
|
|
54
74
|
|
|
55
|
-
const keepAlive = setInterval(() => {
|
|
56
|
-
if (isAlive === false) {
|
|
57
|
-
client.terminate();
|
|
58
|
-
clearInterval(keepAlive);
|
|
59
|
-
} else {
|
|
60
|
-
try {
|
|
61
|
-
client.ping();
|
|
62
|
-
} catch (err) {
|
|
63
|
-
status(err, 'Websocket error');
|
|
64
|
-
}
|
|
65
|
-
isAlive = false;
|
|
66
|
-
}
|
|
67
|
-
}, 10 * 1000);
|
|
68
|
-
|
|
69
75
|
client.on('pong', () => {
|
|
70
76
|
isAlive = true;
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
client.on('close', () => {
|
|
74
80
|
status(null, 'Websocket connection has been closed');
|
|
81
|
+
if (keepAlive) {
|
|
82
|
+
clearInterval(keepAlive);
|
|
83
|
+
}
|
|
75
84
|
if (reconnectOnClose) {
|
|
85
|
+
reconnectAttempts++;
|
|
86
|
+
const delay = Math.min(
|
|
87
|
+
10 * 1000 * Math.pow(2, reconnectAttempts - 1),
|
|
88
|
+
5 * 60 * 1000
|
|
89
|
+
);
|
|
90
|
+
status(
|
|
91
|
+
null,
|
|
92
|
+
`Reconnecting Websocket in ${delay / 1000}s (attempt ${reconnectAttempts})`
|
|
93
|
+
);
|
|
76
94
|
setTimeout(() => {
|
|
77
|
-
status(null, 'Reconnecting Websocket');
|
|
78
95
|
setupWebSocketClient({ status, success, reject });
|
|
79
|
-
},
|
|
96
|
+
}, delay);
|
|
80
97
|
}
|
|
81
|
-
clearInterval(keepAlive);
|
|
82
98
|
});
|
|
83
99
|
};
|
|
84
100
|
|
|
85
101
|
const setupWebSocketServer = ({ status, success, reject }) => {
|
|
86
|
-
|
|
102
|
+
wsServer.ws(path, (ws) => {
|
|
87
103
|
ws.on('message', (message) => {
|
|
88
104
|
if (sample !== 1 && Math.random() > sample) {
|
|
89
105
|
return;
|
package/src/lib/aggregator.js
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
|
-
const { Map, Set, is } = require('immutable');
|
|
1
|
+
const { List, Map, Set, fromJS, is } = require('immutable');
|
|
2
2
|
|
|
3
3
|
const { Formatter, address, identity } = require('../lib/formatter');
|
|
4
4
|
const { Speed } = require('../lib/speed');
|
|
5
|
-
const {
|
|
5
|
+
const {
|
|
6
|
+
aggregateCount,
|
|
7
|
+
aggregateSum,
|
|
8
|
+
formatDuration,
|
|
9
|
+
md5,
|
|
10
|
+
} = require('../lib/util');
|
|
11
|
+
|
|
12
|
+
const lastSeen = (entry) => {
|
|
13
|
+
const ts =
|
|
14
|
+
entry.getIn(['speed', 'per_minute']) &&
|
|
15
|
+
entry.getIn(['speed', 'per_minute']).latest;
|
|
16
|
+
return ts ? new Date(ts * 1000).toISOString() : '';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const statusCount = (key) => (entry) =>
|
|
20
|
+
entry.getIn(['speed', key]) ? aggregateCount(entry, key) : 0;
|
|
6
21
|
|
|
7
22
|
const defaultFormatter = new Formatter();
|
|
8
23
|
defaultFormatter.setFormats([
|
|
9
24
|
['identity', identity],
|
|
10
25
|
|
|
11
|
-
['address', address],
|
|
26
|
+
['address', (entry) => entry.getIn(['address', 'value']) || ''],
|
|
27
|
+
['hostname', address],
|
|
28
|
+
|
|
29
|
+
['count15m', (entry) => aggregateCount(entry, 'per_minute')],
|
|
30
|
+
['count24h', (entry) => aggregateCount(entry, 'per_hour')],
|
|
12
31
|
|
|
13
|
-
['
|
|
14
|
-
['
|
|
32
|
+
['2xx15m', statusCount('2xx_per_minute')],
|
|
33
|
+
['2xx24h', statusCount('2xx_per_hour')],
|
|
34
|
+
['4xx15m', statusCount('4xx_per_minute')],
|
|
35
|
+
['4xx24h', statusCount('4xx_per_hour')],
|
|
36
|
+
|
|
37
|
+
['execTime15m', (entry) => formatDuration(aggregateSum(entry, 'per_minute'))],
|
|
38
|
+
['execTime24h', (entry) => formatDuration(aggregateSum(entry, 'per_hour'))],
|
|
39
|
+
|
|
40
|
+
['lastSeen', lastSeen],
|
|
15
41
|
]);
|
|
16
42
|
|
|
17
43
|
const defaultEnricher = (entry, log) => {
|
|
@@ -42,21 +68,31 @@ const defaultIdentifier = (log) => {
|
|
|
42
68
|
};
|
|
43
69
|
|
|
44
70
|
const defaultSorters = {
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
count15m: (entry) => aggregateCount(entry, 'per_minute'),
|
|
72
|
+
count24h: (entry) => aggregateCount(entry, 'per_hour'),
|
|
73
|
+
'2xx15m': statusCount('2xx_per_minute'),
|
|
74
|
+
'2xx24h': statusCount('2xx_per_hour'),
|
|
75
|
+
'4xx15m': statusCount('4xx_per_minute'),
|
|
76
|
+
'4xx24h': statusCount('4xx_per_hour'),
|
|
47
77
|
latest: (entry) => entry.getIn(['speed', 'per_minute']).latest,
|
|
78
|
+
execTime15m: (entry) => aggregateSum(entry, 'per_minute'),
|
|
79
|
+
execTime24h: (entry) => aggregateSum(entry, 'per_hour'),
|
|
48
80
|
};
|
|
49
81
|
|
|
50
82
|
class Aggregator {
|
|
51
83
|
constructor() {
|
|
52
84
|
this.entries = new Map();
|
|
53
|
-
|
|
85
|
+
// Snapshot the default formats: modules extend defaultFormatter during
|
|
86
|
+
// init, aggregators are created during start, and per-aggregator
|
|
87
|
+
// insertFormat calls must not leak into other aggregators.
|
|
88
|
+
this.formatter = defaultFormatter.clone();
|
|
54
89
|
this.enricher = defaultEnricher;
|
|
55
90
|
this.identifier = defaultIdentifier;
|
|
56
|
-
this.sorters = defaultSorters;
|
|
91
|
+
this.sorters = { ...defaultSorters };
|
|
92
|
+
this.entryGc = null;
|
|
57
93
|
this.gcSize = 1000;
|
|
58
94
|
|
|
59
|
-
setInterval(() => this.gc(), 60 * 1000);
|
|
95
|
+
setInterval(() => this.gc(), 60 * 1000).unref();
|
|
60
96
|
}
|
|
61
97
|
|
|
62
98
|
setFormatter(fn) {
|
|
@@ -71,6 +107,13 @@ class Aggregator {
|
|
|
71
107
|
return this;
|
|
72
108
|
}
|
|
73
109
|
|
|
110
|
+
// Per-entry cleanup run on every gc pass, regardless of aggregator size
|
|
111
|
+
setEntryGc(fn) {
|
|
112
|
+
this.entryGc = fn;
|
|
113
|
+
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
setIdentifier(fn) {
|
|
75
118
|
this.identifier = fn;
|
|
76
119
|
|
|
@@ -80,17 +123,45 @@ class Aggregator {
|
|
|
80
123
|
processLog(log) {
|
|
81
124
|
const identifier = this.identifier(log);
|
|
82
125
|
const id = md5(identifier);
|
|
126
|
+
const rawExecTime = Number(log.get('executionTime'));
|
|
127
|
+
const executionTime = Number.isFinite(rawExecTime) ? rawExecTime : 0;
|
|
128
|
+
|
|
129
|
+
const status = log.getIn(['response', 'status']);
|
|
83
130
|
|
|
84
131
|
if (!this.entries.has(id)) {
|
|
85
132
|
this.entries = this.entries
|
|
86
133
|
.setIn([id, 'id'], id)
|
|
87
134
|
.setIn([id, 'identifier'], identifier)
|
|
88
|
-
.setIn(
|
|
89
|
-
|
|
135
|
+
.setIn(
|
|
136
|
+
[id, 'speed', 'per_minute'],
|
|
137
|
+
new Speed(60, 15).hit(undefined, executionTime)
|
|
138
|
+
)
|
|
139
|
+
.setIn(
|
|
140
|
+
[id, 'speed', 'per_hour'],
|
|
141
|
+
new Speed(3600, 24).hit(undefined, executionTime)
|
|
142
|
+
)
|
|
143
|
+
.setIn([id, 'speed', '2xx_per_minute'], new Speed(60, 15))
|
|
144
|
+
.setIn([id, 'speed', '2xx_per_hour'], new Speed(3600, 24))
|
|
145
|
+
.setIn([id, 'speed', '4xx_per_minute'], new Speed(60, 15))
|
|
146
|
+
.setIn([id, 'speed', '4xx_per_hour'], new Speed(3600, 24));
|
|
90
147
|
} else {
|
|
91
148
|
this.entries = this.entries
|
|
92
|
-
.updateIn([id, 'speed', 'per_minute'], (speed) =>
|
|
93
|
-
|
|
149
|
+
.updateIn([id, 'speed', 'per_minute'], (speed) =>
|
|
150
|
+
speed.hit(undefined, executionTime)
|
|
151
|
+
)
|
|
152
|
+
.updateIn([id, 'speed', 'per_hour'], (speed) =>
|
|
153
|
+
speed.hit(undefined, executionTime)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (status >= 200 && status < 300) {
|
|
158
|
+
this.entries = this.entries
|
|
159
|
+
.updateIn([id, 'speed', '2xx_per_minute'], (speed) => speed.hit())
|
|
160
|
+
.updateIn([id, 'speed', '2xx_per_hour'], (speed) => speed.hit());
|
|
161
|
+
} else if (status >= 400 && status < 500) {
|
|
162
|
+
this.entries = this.entries
|
|
163
|
+
.updateIn([id, 'speed', '4xx_per_minute'], (speed) => speed.hit())
|
|
164
|
+
.updateIn([id, 'speed', '4xx_per_hour'], (speed) => speed.hit());
|
|
94
165
|
}
|
|
95
166
|
|
|
96
167
|
this.entries = this.entries.updateIn([id], (entry) =>
|
|
@@ -106,13 +177,11 @@ class Aggregator {
|
|
|
106
177
|
|
|
107
178
|
getData({ raw, sort, format, limit }) {
|
|
108
179
|
if (!sort || !this.sorters[sort]) {
|
|
109
|
-
sort = '
|
|
180
|
+
sort = 'count15m';
|
|
110
181
|
}
|
|
111
182
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
.sort()
|
|
115
|
-
.reverse()
|
|
183
|
+
const sorted = this.entries.map(this.sorters[sort]).sort().reverse();
|
|
184
|
+
const rawData = sorted
|
|
116
185
|
.slice(0, limit || 100)
|
|
117
186
|
.keySeq()
|
|
118
187
|
.map((id) => this.entries.get(id));
|
|
@@ -124,7 +193,15 @@ class Aggregator {
|
|
|
124
193
|
: rawData.map((entry) => this.formatter.formatObject(entry, output));
|
|
125
194
|
}
|
|
126
195
|
|
|
196
|
+
reset() {
|
|
197
|
+
this.entries = new Map();
|
|
198
|
+
}
|
|
199
|
+
|
|
127
200
|
gc() {
|
|
201
|
+
if (this.entryGc) {
|
|
202
|
+
this.entries = this.entries.map(this.entryGc);
|
|
203
|
+
}
|
|
204
|
+
|
|
128
205
|
if (this.entries.size < this.gcSize) {
|
|
129
206
|
return;
|
|
130
207
|
}
|
|
@@ -142,6 +219,78 @@ class Aggregator {
|
|
|
142
219
|
|
|
143
220
|
this.entries = this.entries.filter((value, key) => keepList.has(key));
|
|
144
221
|
}
|
|
222
|
+
|
|
223
|
+
dump() {
|
|
224
|
+
return this.entries
|
|
225
|
+
.map((entry) => {
|
|
226
|
+
const plain = entry.toJS();
|
|
227
|
+
plain.speed = entry
|
|
228
|
+
.get('speed')
|
|
229
|
+
.map((speed) => speed.toJSON())
|
|
230
|
+
.toObject();
|
|
231
|
+
return plain;
|
|
232
|
+
})
|
|
233
|
+
.valueSeq()
|
|
234
|
+
.toArray();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
load(data) {
|
|
238
|
+
for (const item of data) {
|
|
239
|
+
const { speed, ...rest } = item;
|
|
240
|
+
// fromJS deep-converts everything to Immutable structures.
|
|
241
|
+
// Signature headers must stay as a plain object (used with Object.entries).
|
|
242
|
+
let entry = fromJS(rest);
|
|
243
|
+
if (entry.hasIn(['signature', 'headers'])) {
|
|
244
|
+
entry = entry.setIn(['signature', 'headers'], rest.signature.headers);
|
|
245
|
+
}
|
|
246
|
+
// addresses/signatures are Map<id, lastSeen>. Dumps from before that
|
|
247
|
+
// stored plain arrays, which the enrichers can't update — discard them
|
|
248
|
+
// and let the counts rebuild from live traffic.
|
|
249
|
+
for (const key of ['addresses', 'signatures']) {
|
|
250
|
+
if (List.isList(entry.get(key))) {
|
|
251
|
+
entry = entry.delete(key);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
entry = entry
|
|
255
|
+
.setIn(['speed', 'per_minute'], Speed.fromJSON(speed.per_minute))
|
|
256
|
+
.setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour))
|
|
257
|
+
.setIn(
|
|
258
|
+
['speed', '2xx_per_minute'],
|
|
259
|
+
speed['2xx_per_minute']
|
|
260
|
+
? Speed.fromJSON(speed['2xx_per_minute'])
|
|
261
|
+
: new Speed(60, 15)
|
|
262
|
+
)
|
|
263
|
+
.setIn(
|
|
264
|
+
['speed', '2xx_per_hour'],
|
|
265
|
+
speed['2xx_per_hour']
|
|
266
|
+
? Speed.fromJSON(speed['2xx_per_hour'])
|
|
267
|
+
: new Speed(3600, 24)
|
|
268
|
+
)
|
|
269
|
+
.setIn(
|
|
270
|
+
['speed', '4xx_per_minute'],
|
|
271
|
+
speed['4xx_per_minute']
|
|
272
|
+
? Speed.fromJSON(speed['4xx_per_minute'])
|
|
273
|
+
: new Speed(60, 15)
|
|
274
|
+
)
|
|
275
|
+
.setIn(
|
|
276
|
+
['speed', '4xx_per_hour'],
|
|
277
|
+
speed['4xx_per_hour']
|
|
278
|
+
? Speed.fromJSON(speed['4xx_per_hour'])
|
|
279
|
+
: new Speed(3600, 24)
|
|
280
|
+
);
|
|
281
|
+
this.entries = this.entries.set(rest.id, entry);
|
|
282
|
+
}
|
|
283
|
+
// Drop members that went stale while the process was down
|
|
284
|
+
if (this.entryGc) {
|
|
285
|
+
this.entries = this.entries.map(this.entryGc);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
145
288
|
}
|
|
146
289
|
|
|
147
|
-
module.exports = {
|
|
290
|
+
module.exports = {
|
|
291
|
+
Aggregator,
|
|
292
|
+
defaultFormatter,
|
|
293
|
+
defaultEnricher,
|
|
294
|
+
lastSeen,
|
|
295
|
+
statusCount,
|
|
296
|
+
};
|
package/src/lib/formatter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const chalk = require('chalk');
|
|
1
|
+
const { default: chalk } = require('chalk');
|
|
2
2
|
|
|
3
3
|
const colorize = (name, value, output) => {
|
|
4
4
|
if (output === 'console') {
|
|
@@ -65,6 +65,15 @@ class Formatter {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
clone() {
|
|
69
|
+
const formatter = new Formatter();
|
|
70
|
+
formatter.formats = [...this.formats];
|
|
71
|
+
formatter.colors = { ...this.colors };
|
|
72
|
+
formatter.output = this.output;
|
|
73
|
+
|
|
74
|
+
return formatter;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
setOutput(output) {
|
|
69
78
|
this.output = output;
|
|
70
79
|
|
package/src/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@ const aggregator = require('./aggregator');
|
|
|
2
2
|
const cache = require('./cache');
|
|
3
3
|
const formatter = require('./formatter');
|
|
4
4
|
const logger = require('./logger');
|
|
5
|
+
const persistence = require('./persistence');
|
|
5
6
|
const pipeline = require('./pipeline');
|
|
6
7
|
const util = require('./util');
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ module.exports = {
|
|
|
10
11
|
cache,
|
|
11
12
|
formatter,
|
|
12
13
|
logger,
|
|
14
|
+
persistence,
|
|
13
15
|
pipeline,
|
|
14
16
|
util,
|
|
15
17
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the latest `capacity` logs of a pipeline node, newest first.
|
|
3
|
+
* dump() / load() let the persistence module save it across restarts.
|
|
4
|
+
*/
|
|
5
|
+
const { fromJS } = require('immutable');
|
|
6
|
+
|
|
7
|
+
class LogBuffer {
|
|
8
|
+
constructor(capacity = 1000) {
|
|
9
|
+
this.capacity = capacity;
|
|
10
|
+
this.buffer = new Array(capacity);
|
|
11
|
+
this.pointer = 0;
|
|
12
|
+
this.size = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
push(item) {
|
|
16
|
+
this.buffer[this.pointer] = item;
|
|
17
|
+
this.pointer = (this.pointer + 1) % this.capacity;
|
|
18
|
+
if (this.size < this.capacity) {
|
|
19
|
+
this.size++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toArray() {
|
|
24
|
+
if (this.size < this.capacity) {
|
|
25
|
+
return this.buffer.slice(0, this.size).reverse();
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
...this.buffer.slice(this.pointer),
|
|
29
|
+
...this.buffer.slice(0, this.pointer),
|
|
30
|
+
].reverse();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Oldest first, never more than capacity
|
|
34
|
+
dump() {
|
|
35
|
+
return this.toArray()
|
|
36
|
+
.reverse()
|
|
37
|
+
.map((log) => log.toJS());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
load(data) {
|
|
41
|
+
data.slice(-this.capacity).forEach((item) => this.push(fromJS(item)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = LogBuffer;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const debug = require('debug');
|
|
5
|
+
|
|
6
|
+
const debugPersistence = debug('hyperwatch:persistence');
|
|
7
|
+
|
|
8
|
+
const aggregators = Object.create(null);
|
|
9
|
+
|
|
10
|
+
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
|
|
11
|
+
|
|
12
|
+
// Encode any string into a name accepted by register(). Characters outside
|
|
13
|
+
// [A-Za-z0-9.-], including '_' itself, become _<hex code point>_, so distinct
|
|
14
|
+
// inputs never map to the same name.
|
|
15
|
+
function safeName(name) {
|
|
16
|
+
return String(name).replace(
|
|
17
|
+
/[^A-Za-z0-9.-]/gu,
|
|
18
|
+
(c) => `_${c.codePointAt(0).toString(16)}_`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function register(name, aggregator) {
|
|
23
|
+
if (!SAFE_NAME.test(name)) {
|
|
24
|
+
throw new Error(`Invalid aggregator name for persistence: "${name}"`);
|
|
25
|
+
}
|
|
26
|
+
aggregators[name] = aggregator;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dump(dir) {
|
|
30
|
+
if (!fs.existsSync(dir)) {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
for (const [name, aggregator] of Object.entries(aggregators)) {
|
|
34
|
+
const data = aggregator.dump();
|
|
35
|
+
const target = path.join(dir, `${name}.json`);
|
|
36
|
+
const tmp = path.join(dir, `${name}.json.tmp`);
|
|
37
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
38
|
+
fs.renameSync(tmp, target);
|
|
39
|
+
}
|
|
40
|
+
debugPersistence(
|
|
41
|
+
`Dumped ${Object.keys(aggregators).length} aggregator(s) to ${dir}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function load(dir) {
|
|
46
|
+
if (!fs.existsSync(dir)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let files;
|
|
50
|
+
try {
|
|
51
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
debugPersistence(
|
|
54
|
+
`Cannot read persistence directory ${dir}: ${err.message}`
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let loaded = 0;
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const name = path.basename(file, '.json');
|
|
61
|
+
if (aggregators[name]) {
|
|
62
|
+
try {
|
|
63
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
64
|
+
if (!Array.isArray(data)) {
|
|
65
|
+
debugPersistence(
|
|
66
|
+
`Skipping ${file}: expected array, got ${typeof data}`
|
|
67
|
+
);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
aggregators[name].load(data);
|
|
71
|
+
loaded += data.length;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
debugPersistence(`Skipping ${file}: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (loaded) {
|
|
78
|
+
debugPersistence(`Loaded ${loaded} entries from ${dir}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { register, dump, load, safeName };
|