@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.
Files changed (57) hide show
  1. package/README.md +9 -4
  2. package/config/apache_syslog_example.js +29 -0
  3. package/config/default.js +2 -2
  4. package/config/example.js +5 -5
  5. package/config/express_websocket_example.js +28 -0
  6. package/config/websocket_client_example.js +52 -0
  7. package/docs/configuration.md +67 -6
  8. package/docs/express-embedding.md +137 -0
  9. package/docs/input.md +50 -24
  10. package/docs/tutorials/apache_input.md +16 -14
  11. package/docs/tutorials/express_input.md +10 -8
  12. package/package.json +42 -35
  13. package/scripts/fetch-anthropic-ips.js +60 -0
  14. package/scripts/fetch-cloudflare-ips.js +27 -0
  15. package/scripts/fetch-cloudfront-ips.js +29 -0
  16. package/scripts/fetch-openai-ips.js +34 -0
  17. package/src/app/api.js +149 -8
  18. package/src/app/index.js +8 -4
  19. package/src/app/mount.js +115 -0
  20. package/src/app/websocket.js +39 -10
  21. package/src/app/ws-server.js +123 -0
  22. package/src/constants.js +17 -5
  23. package/src/data/amazon-searchbot-ips.json +818 -0
  24. package/src/data/amazon-user-ips.json +1025 -0
  25. package/src/data/amazonbot-ips.json +1294 -0
  26. package/src/data/chatgpt-user-ips.json +231 -0
  27. package/src/data/claude-bot-ips.json +28 -0
  28. package/src/data/cloudflare-ips.json +24 -0
  29. package/src/data/cloudfront-ips.json +245 -0
  30. package/src/data/gptbot-ips.json +20 -0
  31. package/src/data/openai-searchbot-ips.json +41 -0
  32. package/src/index.js +24 -3
  33. package/src/input/http.js +4 -0
  34. package/src/input/syslog.js +5 -1
  35. package/src/input/websocket.js +35 -19
  36. package/src/lib/aggregator.js +169 -20
  37. package/src/lib/formatter.js +10 -1
  38. package/src/lib/index.js +2 -0
  39. package/src/lib/log-buffer.js +45 -0
  40. package/src/lib/persistence.js +82 -0
  41. package/src/lib/pipeline.js +122 -11
  42. package/src/lib/recent-map.js +23 -0
  43. package/src/lib/speed.js +43 -3
  44. package/src/lib/util.js +17 -1
  45. package/src/modules/address.js +59 -2
  46. package/src/modules/agent.js +1 -1
  47. package/src/modules/dnsbl.js +11 -1
  48. package/src/modules/history.js +62 -0
  49. package/src/modules/identity.js +154 -26
  50. package/src/modules/index.js +16 -5
  51. package/src/modules/language.js +1 -1
  52. package/src/modules/signature.js +64 -18
  53. package/src/modules/sparkline.js +7 -3
  54. package/src/modules/status.js +13 -8
  55. package/src/plugins/proxy.js +10 -203
  56. package/src/script.js +0 -1
  57. package/scripts/cloudfront-ips.js +0 -56
@@ -1,7 +1,7 @@
1
1
  const { fromJS } = require('immutable');
2
2
  const WebSocket = require('ws');
3
3
 
4
- const app = require('../app/websocket');
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
- }, 10 * 1000);
96
+ }, delay);
80
97
  }
81
- clearInterval(keepAlive);
82
98
  });
83
99
  };
84
100
 
85
101
  const setupWebSocketServer = ({ status, success, reject }) => {
86
- app.ws(path, (ws) => {
102
+ wsServer.ws(path, (ws) => {
87
103
  ws.on('message', (message) => {
88
104
  if (sample !== 1 && Math.random() > sample) {
89
105
  return;
@@ -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 { aggregateSpeed, md5 } = require('../lib/util');
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
- ['15m', (entry) => aggregateSpeed(entry, 'per_minute')],
14
- ['24h', (entry) => aggregateSpeed(entry, 'per_hour')],
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
- '15m': (entry) => aggregateSpeed(entry, 'per_minute'),
46
- '24h': (entry) => aggregateSpeed(entry, 'per_hour'),
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
- this.formatter = defaultFormatter;
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([id, 'speed', 'per_minute'], new Speed(60, 15).hit())
89
- .setIn([id, 'speed', 'per_hour'], new Speed(3600, 24).hit());
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) => speed.hit())
93
- .updateIn([id, 'speed', 'per_hour'], (speed) => speed.hit());
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 = '15m';
180
+ sort = 'count15m';
110
181
  }
111
182
 
112
- const rawData = this.entries
113
- .map(this.sorters[sort])
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 = { Aggregator, defaultFormatter, defaultEnricher };
290
+ module.exports = {
291
+ Aggregator,
292
+ defaultFormatter,
293
+ defaultEnricher,
294
+ lastSeen,
295
+ statusCount,
296
+ };
@@ -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 };