@hyperwatch/hyperwatch 4.3.1 → 5.0.1

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 (45) hide show
  1. package/README.md +9 -4
  2. package/config/apache_syslog_example.js +1 -1
  3. package/config/default.js +1 -1
  4. package/config/example.js +4 -4
  5. package/config/express_websocket_example.js +1 -1
  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 +16 -14
  13. package/scripts/fetch-anthropic-ips.js +60 -0
  14. package/src/app/api.js +129 -3
  15. package/src/app/index.js +8 -4
  16. package/src/app/mount.js +115 -0
  17. package/src/app/websocket.js +12 -9
  18. package/src/app/ws-server.js +123 -0
  19. package/src/constants.js +8 -5
  20. package/src/data/amazon-searchbot-ips.json +304 -0
  21. package/src/data/amazonbot-ips.json +775 -1
  22. package/src/data/chatgpt-user-ips.json +115 -112
  23. package/src/data/claude-bot-ips.json +28 -0
  24. package/src/data/cloudfront-ips.json +14 -0
  25. package/src/data/gptbot-ips.json +0 -3
  26. package/src/data/openai-searchbot-ips.json +4 -0
  27. package/src/index.js +6 -1
  28. package/src/input/http.js +4 -0
  29. package/src/input/syslog.js +5 -1
  30. package/src/input/websocket.js +57 -21
  31. package/src/lib/aggregator.js +112 -19
  32. package/src/lib/formatter.js +10 -1
  33. package/src/lib/log-buffer.js +45 -0
  34. package/src/lib/persistence.js +11 -1
  35. package/src/lib/pipeline.js +140 -14
  36. package/src/lib/recent-map.js +23 -0
  37. package/src/modules/address.js +59 -2
  38. package/src/modules/dnsbl.js +11 -1
  39. package/src/modules/history.js +4 -28
  40. package/src/modules/hostname.js +12 -4
  41. package/src/modules/identity.js +92 -9
  42. package/src/modules/index.js +14 -5
  43. package/src/modules/signature.js +49 -14
  44. package/src/modules/sparkline.js +7 -3
  45. package/src/modules/status.js +6 -1
@@ -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
 
@@ -19,12 +19,20 @@ function create({
19
19
  heartbeatInterval = 30000,
20
20
  }) {
21
21
  let client;
22
+ let keepAlive;
23
+ let reconnectTimer;
24
+ // Each connection belongs to a generation. stop() and every new connection
25
+ // move to the next one, so events from an obsolete socket (a late close
26
+ // after a restart, its heartbeat) can't reconnect or touch the current one.
27
+ let generation = 0;
22
28
 
23
29
  let reconnectAttempts = 0;
24
30
 
25
31
  const setupWebSocketClient = ({ status, success, reject }) => {
32
+ const current = ++generation;
33
+ const isCurrent = () => current === generation;
26
34
  let isAlive;
27
- let keepAlive;
35
+ let heartbeat;
28
36
 
29
37
  if (username && password) {
30
38
  options.headers = options.headers || {};
@@ -33,32 +41,37 @@ function create({
33
41
  ).toString('base64')}`;
34
42
  }
35
43
 
36
- client = new WebSocket(address, [], options);
44
+ const socket = new WebSocket(address, [], options);
45
+ client = socket;
37
46
  status(null, `Waiting for connection to ${address}`);
38
47
 
39
- client.on('open', () => {
48
+ socket.on('open', () => {
49
+ if (!isCurrent()) {
50
+ return;
51
+ }
40
52
  isAlive = true;
41
53
  reconnectAttempts = 0;
42
54
  status(null, `Listening to ${address}`);
43
55
 
44
56
  // Heartbeat: detect stale connections
45
- keepAlive = setInterval(() => {
57
+ heartbeat = setInterval(() => {
46
58
  if (isAlive === false) {
47
- client.terminate();
48
- clearInterval(keepAlive);
59
+ socket.terminate();
60
+ clearInterval(heartbeat);
49
61
  } else {
50
62
  try {
51
- client.ping();
63
+ socket.ping();
52
64
  } catch (err) {
53
65
  status(err, 'Websocket error');
54
66
  }
55
67
  isAlive = false;
56
68
  }
57
69
  }, heartbeatInterval);
70
+ keepAlive = heartbeat;
58
71
  });
59
72
 
60
- client.on('message', (message) => {
61
- if (sample !== 1 && Math.random() > sample) {
73
+ socket.on('message', (message) => {
74
+ if (!isCurrent() || (sample !== 1 && Math.random() > sample)) {
62
75
  return;
63
76
  }
64
77
  try {
@@ -68,19 +81,22 @@ function create({
68
81
  }
69
82
  });
70
83
 
71
- client.on('error', (err) => {
72
- status(err, 'Websocket error');
84
+ socket.on('error', (err) => {
85
+ if (isCurrent()) {
86
+ status(err, 'Websocket error');
87
+ }
73
88
  });
74
89
 
75
- client.on('pong', () => {
90
+ socket.on('pong', () => {
76
91
  isAlive = true;
77
92
  });
78
93
 
79
- client.on('close', () => {
80
- status(null, 'Websocket connection has been closed');
81
- if (keepAlive) {
82
- clearInterval(keepAlive);
94
+ socket.on('close', () => {
95
+ clearInterval(heartbeat);
96
+ if (!isCurrent()) {
97
+ return;
83
98
  }
99
+ status(null, 'Websocket connection has been closed');
84
100
  if (reconnectOnClose) {
85
101
  reconnectAttempts++;
86
102
  const delay = Math.min(
@@ -91,7 +107,7 @@ function create({
91
107
  null,
92
108
  `Reconnecting Websocket in ${delay / 1000}s (attempt ${reconnectAttempts})`
93
109
  );
94
- setTimeout(() => {
110
+ reconnectTimer = setTimeout(() => {
95
111
  setupWebSocketClient({ status, success, reject });
96
112
  }, delay);
97
113
  }
@@ -99,7 +115,7 @@ function create({
99
115
  };
100
116
 
101
117
  const setupWebSocketServer = ({ status, success, reject }) => {
102
- app.ws(path, (ws) => {
118
+ wsServer.ws(path, (ws) => {
103
119
  ws.on('message', (message) => {
104
120
  if (sample !== 1 && Math.random() > sample) {
105
121
  return;
@@ -117,6 +133,7 @@ function create({
117
133
  return {
118
134
  name: `${name} ${type}`,
119
135
  start: ({ success, reject, status, log }) => {
136
+ reconnectAttempts = 0;
120
137
  if (type === 'client') {
121
138
  setupWebSocketClient({ status, success, reject });
122
139
  } else if (type === 'server') {
@@ -126,9 +143,28 @@ function create({
126
143
  log(new Error(errMsg), 'error');
127
144
  }
128
145
  },
146
+ // Never throws: a failing input must not prevent the others from stopping,
147
+ // nor Hyperwatch from persisting its data on shutdown.
129
148
  stop: () => {
130
- if (client) {
131
- client.close();
149
+ // Obsoletes the current connection: its close won't reconnect
150
+ generation++;
151
+ clearTimeout(reconnectTimer);
152
+ clearInterval(keepAlive);
153
+ if (!client) {
154
+ return;
155
+ }
156
+ try {
157
+ if (client.readyState === WebSocket.CONNECTING) {
158
+ // close() throws while the connection is not established yet
159
+ client.terminate();
160
+ } else if (client.readyState === WebSocket.OPEN) {
161
+ client.close();
162
+ }
163
+ } catch (err) {
164
+ console.error(
165
+ `${name}: error while closing the Websocket:`,
166
+ err.message
167
+ );
132
168
  }
133
169
  },
134
170
  };
@@ -1,4 +1,4 @@
1
- const { Map, Set, fromJS, 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');
@@ -9,17 +9,35 @@ const {
9
9
  md5,
10
10
  } = require('../lib/util');
11
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;
21
+
12
22
  const defaultFormatter = new Formatter();
13
23
  defaultFormatter.setFormats([
14
24
  ['identity', identity],
15
25
 
16
- ['address', address],
26
+ ['address', (entry) => entry.getIn(['address', 'value']) || ''],
27
+ ['hostname', address],
17
28
 
18
29
  ['count15m', (entry) => aggregateCount(entry, 'per_minute')],
19
30
  ['count24h', (entry) => aggregateCount(entry, 'per_hour')],
20
31
 
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
+
21
37
  ['execTime15m', (entry) => formatDuration(aggregateSum(entry, 'per_minute'))],
22
38
  ['execTime24h', (entry) => formatDuration(aggregateSum(entry, 'per_hour'))],
39
+
40
+ ['lastSeen', lastSeen],
23
41
  ]);
24
42
 
25
43
  const defaultEnricher = (entry, log) => {
@@ -52,6 +70,10 @@ const defaultIdentifier = (log) => {
52
70
  const defaultSorters = {
53
71
  count15m: (entry) => aggregateCount(entry, 'per_minute'),
54
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'),
55
77
  latest: (entry) => entry.getIn(['speed', 'per_minute']).latest,
56
78
  execTime15m: (entry) => aggregateSum(entry, 'per_minute'),
57
79
  execTime24h: (entry) => aggregateSum(entry, 'per_hour'),
@@ -60,10 +82,14 @@ const defaultSorters = {
60
82
  class Aggregator {
61
83
  constructor() {
62
84
  this.entries = new Map();
63
- 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();
64
89
  this.enricher = defaultEnricher;
65
90
  this.identifier = defaultIdentifier;
66
- this.sorters = defaultSorters;
91
+ this.sorters = { ...defaultSorters };
92
+ this.entryGc = null;
67
93
  this.gcSize = 1000;
68
94
 
69
95
  setInterval(() => this.gc(), 60 * 1000).unref();
@@ -81,6 +107,13 @@ class Aggregator {
81
107
  return this;
82
108
  }
83
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
+
84
117
  setIdentifier(fn) {
85
118
  this.identifier = fn;
86
119
 
@@ -93,6 +126,8 @@ class Aggregator {
93
126
  const rawExecTime = Number(log.get('executionTime'));
94
127
  const executionTime = Number.isFinite(rawExecTime) ? rawExecTime : 0;
95
128
 
129
+ const status = log.getIn(['response', 'status']);
130
+
96
131
  if (!this.entries.has(id)) {
97
132
  this.entries = this.entries
98
133
  .setIn([id, 'id'], id)
@@ -104,7 +139,11 @@ class Aggregator {
104
139
  .setIn(
105
140
  [id, 'speed', 'per_hour'],
106
141
  new Speed(3600, 24).hit(undefined, executionTime)
107
- );
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));
108
147
  } else {
109
148
  this.entries = this.entries
110
149
  .updateIn([id, 'speed', 'per_minute'], (speed) =>
@@ -115,6 +154,16 @@ class Aggregator {
115
154
  );
116
155
  }
117
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());
165
+ }
166
+
118
167
  this.entries = this.entries.updateIn([id], (entry) =>
119
168
  this.enricher(entry, log)
120
169
  );
@@ -131,10 +180,8 @@ class Aggregator {
131
180
  sort = 'count15m';
132
181
  }
133
182
 
134
- const rawData = this.entries
135
- .map(this.sorters[sort])
136
- .sort()
137
- .reverse()
183
+ const sorted = this.entries.map(this.sorters[sort]).sort().reverse();
184
+ const rawData = sorted
138
185
  .slice(0, limit || 100)
139
186
  .keySeq()
140
187
  .map((id) => this.entries.get(id));
@@ -146,7 +193,15 @@ class Aggregator {
146
193
  : rawData.map((entry) => this.formatter.formatObject(entry, output));
147
194
  }
148
195
 
196
+ reset() {
197
+ this.entries = new Map();
198
+ }
199
+
149
200
  gc() {
201
+ if (this.entryGc) {
202
+ this.entries = this.entries.map(this.entryGc);
203
+ }
204
+
150
205
  if (this.entries.size < this.gcSize) {
151
206
  return;
152
207
  }
@@ -169,10 +224,10 @@ class Aggregator {
169
224
  return this.entries
170
225
  .map((entry) => {
171
226
  const plain = entry.toJS();
172
- plain.speed = {
173
- per_minute: entry.getIn(['speed', 'per_minute']).toJSON(),
174
- per_hour: entry.getIn(['speed', 'per_hour']).toJSON(),
175
- };
227
+ plain.speed = entry
228
+ .get('speed')
229
+ .map((speed) => speed.toJSON())
230
+ .toObject();
176
231
  return plain;
177
232
  })
178
233
  .valueSeq()
@@ -183,21 +238,59 @@ class Aggregator {
183
238
  for (const item of data) {
184
239
  const { speed, ...rest } = item;
185
240
  // fromJS deep-converts everything to Immutable structures.
186
- // Signature headers must stay as a plain object (used with Object.entries),
187
- // and addresses must be a Set, not a List — fix both after conversion.
241
+ // Signature headers must stay as a plain object (used with Object.entries).
188
242
  let entry = fromJS(rest);
189
243
  if (entry.hasIn(['signature', 'headers'])) {
190
244
  entry = entry.setIn(['signature', 'headers'], rest.signature.headers);
191
245
  }
192
- if (entry.has('addresses')) {
193
- entry = entry.update('addresses', (list) => Set(list));
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
+ }
194
253
  }
195
254
  entry = entry
196
255
  .setIn(['speed', 'per_minute'], Speed.fromJSON(speed.per_minute))
197
- .setIn(['speed', 'per_hour'], Speed.fromJSON(speed.per_hour));
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
+ );
198
281
  this.entries = this.entries.set(rest.id, entry);
199
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
+ }
200
287
  }
201
288
  }
202
289
 
203
- 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
 
@@ -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;
@@ -9,6 +9,16 @@ const aggregators = Object.create(null);
9
9
 
10
10
  const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
11
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
+
12
22
  function register(name, aggregator) {
13
23
  if (!SAFE_NAME.test(name)) {
14
24
  throw new Error(`Invalid aggregator name for persistence: "${name}"`);
@@ -69,4 +79,4 @@ function load(dir) {
69
79
  }
70
80
  }
71
81
 
72
- module.exports = { register, dump, load };
82
+ module.exports = { register, dump, load, safeName };