@hyperwatch/hyperwatch 4.3.1 → 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 (43) 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/input/http.js +4 -0
  28. package/src/input/syslog.js +5 -1
  29. package/src/input/websocket.js +2 -2
  30. package/src/lib/aggregator.js +112 -19
  31. package/src/lib/formatter.js +10 -1
  32. package/src/lib/log-buffer.js +45 -0
  33. package/src/lib/persistence.js +11 -1
  34. package/src/lib/pipeline.js +122 -11
  35. package/src/lib/recent-map.js +23 -0
  36. package/src/modules/address.js +59 -2
  37. package/src/modules/dnsbl.js +11 -1
  38. package/src/modules/history.js +4 -28
  39. package/src/modules/identity.js +57 -8
  40. package/src/modules/index.js +14 -5
  41. package/src/modules/signature.js +49 -14
  42. package/src/modules/sparkline.js +7 -3
  43. package/src/modules/status.js +6 -1
@@ -1,42 +1,18 @@
1
1
  const { api } = require('../app');
2
2
  const constants = require('../constants');
3
+ const LogBuffer = require('../lib/log-buffer');
4
+ const persistence = require('../lib/persistence');
3
5
  const pipeline = require('../lib/pipeline');
4
6
 
5
- class CircularBuffer {
6
- constructor(capacity = 1000) {
7
- this.capacity = capacity;
8
- this.buffer = new Array(capacity);
9
- this.pointer = 0;
10
- this.size = 0;
11
- }
12
-
13
- push(item) {
14
- this.buffer[this.pointer] = item;
15
- this.pointer = (this.pointer + 1) % this.capacity;
16
- if (this.size < this.capacity) {
17
- this.size++;
18
- }
19
- }
20
-
21
- toArray() {
22
- if (this.size < this.capacity) {
23
- return this.buffer.slice(0, this.size).reverse();
24
- }
25
- return [
26
- ...this.buffer.slice(this.pointer),
27
- ...this.buffer.slice(0, this.pointer),
28
- ].reverse();
29
- }
30
- }
31
-
32
7
  function start() {
33
8
  const capacity =
34
9
  (constants.modules.history && constants.modules.history.capacity) || 1000;
35
10
  const buffers = {};
36
11
 
37
12
  function registerNodeHistory(name, node) {
38
- const buffer = new CircularBuffer(capacity);
13
+ const buffer = new LogBuffer(capacity);
39
14
  buffers[name] = buffer;
15
+ persistence.register(`history-${persistence.safeName(name)}`, buffer);
40
16
 
41
17
  node.map((log) => {
42
18
  buffer.push(log);
@@ -3,21 +3,29 @@ const IPCIDR = require('ip-cidr').default;
3
3
  const api = require('../app/api');
4
4
  // Bot IP lists for identity verification
5
5
  // Run `node scripts/fetch-openai-ips.js` to update OpenAI lists
6
+ // Run `node scripts/fetch-anthropic-ips.js` to update the Claude list
6
7
  const amazonSearchBotIps = require('../data/amazon-searchbot-ips.json');
7
8
  const amazonUserIps = require('../data/amazon-user-ips.json');
8
9
  const amazonBotIps = require('../data/amazonbot-ips.json');
9
10
  const chatgptUserIps = require('../data/chatgpt-user-ips.json');
11
+ const claudeBotIps = require('../data/claude-bot-ips.json');
10
12
  const gptbotIps = require('../data/gptbot-ips.json');
11
13
  const openaiSearchbotIps = require('../data/openai-searchbot-ips.json');
12
14
  const { Aggregator } = require('../lib/aggregator');
13
15
  const pipeline = require('../lib/pipeline');
14
16
 
17
+ // Anthropic publishes one list of ranges covering all Claude crawlers.
18
+ // Reverse DNS is not usable here: Claude crawlers run on shared cloud
19
+ // infrastructure, so their PTR records are not Anthropic-controlled.
20
+ const claudeBotCidrs = claudeBotIps.map((cidr) => new IPCIDR(cidr));
21
+
15
22
  function augment(log) {
16
23
  const family = log.getIn(['agent', 'family']);
17
24
  const hostname = log.getIn(['address', 'hostname']);
18
25
  const address =
19
26
  log.getIn(['address', 'value']) || log.getIn(['request', 'address']);
20
27
  const signature = log.getIn(['signature', 'id']);
28
+ const signatureAgent = log.getIn(['request', 'headers', 'signature-agent']);
21
29
 
22
30
  switch (family) {
23
31
  // Per hostname
@@ -177,6 +185,11 @@ function augment(log) {
177
185
  return hostname && hostname.endsWith('.babbar.eu')
178
186
  ? log.set('identity', 'Babbar')
179
187
  : log;
188
+ case 'Reflectionbot':
189
+ // https://reflection.ai/bot
190
+ return hostname && hostname.endsWith('.reflection.ai')
191
+ ? log.set('identity', 'Reflection')
192
+ : log;
180
193
  case 'bnf.fr bot':
181
194
  return hostname && hostname.endsWith('.bnf.fr')
182
195
  ? log.set('identity', 'BnF.fr')
@@ -237,6 +250,22 @@ function augment(log) {
237
250
  return hostname && hostname.endsWith('.blex.seranking.com')
238
251
  ? log.set('identity', 'SE Ranking')
239
252
  : log;
253
+ case 'SofyaBot':
254
+ return hostname && hostname.endsWith('.sofya.co')
255
+ ? log.set('identity', 'Sofya')
256
+ : log;
257
+ case 'YouBot':
258
+ // https://docs.you.com/youbot
259
+ return hostname && hostname.endsWith('.search.you.com')
260
+ ? log.set('identity', 'You.com')
261
+ : log;
262
+ case 'AIWebIndex':
263
+ case 'AIWebIndex-Agent':
264
+ // Lyrenth AI-readable web index, forward-confirmed rDNS under lyrenth.com
265
+ // https://lyrenth.com/bot
266
+ return hostname && hostname.endsWith('.lyrenth.com')
267
+ ? log.set('identity', 'Lyrenth')
268
+ : log;
240
269
 
241
270
  // Per hostname + CIDR
242
271
  case 'Twitterbot':
@@ -291,11 +320,18 @@ function augment(log) {
291
320
  return chatgptUserIps.some((cidr) => new IPCIDR(cidr).contains(address))
292
321
  ? log.set('identity', 'ChatGPT')
293
322
  : log;
323
+ case 'ClaudeBot':
324
+ case 'Claude-User':
325
+ case 'Claude-SearchBot':
326
+ case 'Claude-Web':
327
+ case 'anthropic-ai':
328
+ // https://claude.com/crawling/bots.json
329
+ return address && claudeBotCidrs.some((cidr) => cidr.contains(address))
330
+ ? log.set('identity', 'Claude')
331
+ : log;
294
332
  case 'meta-externalagent':
295
333
  case 'meta-webindexer':
296
- return address &&
297
- (new IPCIDR('2a03:2880::/29').contains(address) ||
298
- new IPCIDR('2a06:98c0:3600::/48').contains(address))
334
+ return address && new IPCIDR('2a03:2880::/29').contains(address)
299
335
  ? log.set('identity', 'Meta')
300
336
  : log;
301
337
 
@@ -335,10 +371,6 @@ function augment(log) {
335
371
  hostname.endsWith('.eu-central-1.compute.amazonaws.com')
336
372
  ? log.set('identity', 'Wise')
337
373
  : log;
338
- case 'ClaudeBot':
339
- return hostname && hostname.endsWith('.us-east-2.compute.amazonaws.com')
340
- ? log.set('identity', 'Claude')
341
- : log;
342
374
  case 'PerplexityBot':
343
375
  return hostname && hostname.endsWith('.compute-1.amazonaws.com')
344
376
  ? log.set('identity', 'Perplexity')
@@ -365,6 +397,12 @@ function augment(log) {
365
397
  return hostname && hostname.endsWith('.googleusercontent.com')
366
398
  ? log.set('identity', 'Dolfe')
367
399
  : log;
400
+ case 'ShapBot':
401
+ // Parallel Web Systems crawler, runs on GCE
402
+ // https://docs.parallel.ai/resources/crawler
403
+ return hostname && hostname.endsWith('.googleusercontent.com')
404
+ ? log.set('identity', 'Parallel')
405
+ : log;
368
406
 
369
407
  // Hetzner
370
408
  case 'Ubermetrics':
@@ -391,6 +429,15 @@ function augment(log) {
391
429
  return hostname && hostname.endsWith('.flipboard.com')
392
430
  ? log.set('identity', 'Flipboard')
393
431
  : log;
432
+
433
+ // Web Bot Auth
434
+ case 'ExaSearchBot':
435
+ // Exa search crawler. No published IP ranges or reverse DNS: requests
436
+ // are signed (RFC 9421) and carry a `Signature-Agent` header
437
+ // https://crawler.exa.ai/
438
+ return signatureAgent && signatureAgent.includes('https://crawler.exa.ai')
439
+ ? log.set('identity', 'Exa')
440
+ : log;
394
441
  }
395
442
 
396
443
  // Hostname only
@@ -448,7 +495,9 @@ function start() {
448
495
 
449
496
  aggregator.setIdentifier(identifier);
450
497
 
451
- pipeline.getNode('main').map((log) => aggregator.processLog(log));
498
+ pipeline
499
+ .getNode('main')
500
+ .map((log) => aggregator.processLog(log), 'aggregator');
452
501
 
453
502
  api.registerAggregator('identities', aggregator);
454
503
  }
@@ -1,6 +1,7 @@
1
1
  const debug = require('debug');
2
2
 
3
3
  const constants = require('../constants');
4
+ const pipeline = require('../lib/pipeline');
4
5
 
5
6
  const debugModules = debug('hyperwatch:modules');
6
7
 
@@ -38,27 +39,35 @@ function get(module) {
38
39
  }
39
40
  }
40
41
 
41
- function activeModules() {
42
+ function activeModulesWithKeys() {
42
43
  return Object.keys(constants.modules)
43
44
  .map((key) => Object.assign({ key }, constants.modules[key]))
44
45
  .sort((a, b) => a.priority - b.priority)
45
46
  .filter((m) => m.active === true)
46
- .map((m) => get(m.key))
47
- .filter((m) => m);
47
+ .map((m) => ({ key: m.key, module: get(m.key) }))
48
+ .filter((m) => m.module);
49
+ }
50
+
51
+ function activeModules() {
52
+ return activeModulesWithKeys().map((m) => m.module);
48
53
  }
49
54
 
50
55
  function init() {
51
- for (const module of activeModules()) {
56
+ for (const { key, module } of activeModulesWithKeys()) {
52
57
  if (module && module.init) {
58
+ pipeline.currentModule = key;
53
59
  module.init();
60
+ pipeline.currentModule = null;
54
61
  }
55
62
  }
56
63
  }
57
64
 
58
65
  function start() {
59
- for (const module of activeModules()) {
66
+ for (const { key, module } of activeModulesWithKeys()) {
60
67
  if (module && module.start) {
68
+ pipeline.currentModule = key;
61
69
  module.start();
70
+ pipeline.currentModule = null;
62
71
  }
63
72
  }
64
73
  }
@@ -1,9 +1,10 @@
1
- const { is, Set } = require('immutable');
1
+ const { is } = require('immutable');
2
2
 
3
3
  const api = require('../app/api');
4
- const { Aggregator } = require('../lib/aggregator');
4
+ const { Aggregator, lastSeen, statusCount } = require('../lib/aggregator');
5
5
  const { Formatter } = require('../lib/formatter');
6
6
  const pipeline = require('../lib/pipeline');
7
+ const { touch, prune, countRecent } = require('../lib/recent-map');
7
8
  const {
8
9
  aggregateCount,
9
10
  aggregateSum,
@@ -40,6 +41,9 @@ function normalisedIdentityHeader(headers) {
40
41
  return obj;
41
42
  }
42
43
 
44
+ const addressCount15m = (entry) => countRecent(entry.get('addresses'), 15 * 60);
45
+ const addressCount24h = (entry) => countRecent(entry.get('addresses'));
46
+
43
47
  function computeSignature(headers) {
44
48
  const string = Object.keys(headers)
45
49
  .map((key) => [key, headers[key]].join(':'))
@@ -65,8 +69,10 @@ function init() {
65
69
  pipeline.getNode('main').map(augment).registerNode('main');
66
70
  }
67
71
 
72
+ let _aggregator;
73
+
68
74
  function start() {
69
- const aggregator = new Aggregator();
75
+ const aggregator = (_aggregator = new Aggregator());
70
76
 
71
77
  aggregator.setIdentifier((log) => log.getIn(['signature', 'id']));
72
78
 
@@ -75,15 +81,24 @@ function start() {
75
81
  signatureFormatter.setFormats([
76
82
  ['signature', (entry) => entry.getIn(['signature', 'id'])],
77
83
  ['identity', (entry) => entry.get('identity')],
78
- ['addressCount', (entry) => entry.get('addresses').size],
84
+ ['addressCount15m', addressCount15m],
85
+ ['addressCount24h', addressCount24h],
79
86
  [
80
87
  'addresses',
81
88
  (entry) =>
82
- entry
83
- .get('addresses')
84
- .map((address) => address.get('value'))
85
- .slice(0, 10)
86
- .join('<br>'),
89
+ entry.has('addresses')
90
+ ? entry.get('addresses').keySeq().slice(0, 10).join('<br>')
91
+ : '',
92
+ ],
93
+ [
94
+ 'lastAddress',
95
+ (entry) => {
96
+ const addr = entry.get('lastAddress');
97
+ if (!addr) {
98
+ return '';
99
+ }
100
+ return addr.get('hostname') || addr.get('value') || '';
101
+ },
87
102
  ],
88
103
 
89
104
  [
@@ -96,9 +111,15 @@ function start() {
96
111
  },
97
112
  ],
98
113
 
114
+ ['lastSeen', lastSeen],
99
115
  ['count15m', (entry) => aggregateCount(entry, 'per_minute')],
100
116
  ['count24h', (entry) => aggregateCount(entry, 'per_hour')],
101
117
 
118
+ ['2xx15m', statusCount('2xx_per_minute')],
119
+ ['2xx24h', statusCount('2xx_per_hour')],
120
+ ['4xx15m', statusCount('4xx_per_minute')],
121
+ ['4xx24h', statusCount('4xx_per_hour')],
122
+
102
123
  [
103
124
  'execTime15m',
104
125
  (entry) => formatDuration(aggregateSum(entry, 'per_minute')),
@@ -121,10 +142,12 @@ function start() {
121
142
  }
122
143
 
123
144
  const address = log.get('address');
124
- if (!entry.has('addresses')) {
125
- entry = entry.set('addresses', new Set([address]));
126
- } else if (!entry.get('addresses').has(address)) {
127
- entry = entry.update('addresses', (set) => set.add(address));
145
+ entry = entry.set('lastAddress', address);
146
+ // Distinct IPs with last-seen time, pruned to 24h; backs both the 15m
147
+ // and 24h counts
148
+ const value = address && address.get('value');
149
+ if (value) {
150
+ entry = entry.update('addresses', (map) => touch(map, value));
128
151
  }
129
152
 
130
153
  return entry;
@@ -132,7 +155,16 @@ function start() {
132
155
 
133
156
  aggregator.setEnricher(enricher);
134
157
 
135
- pipeline.getNode('main').map((log) => aggregator.processLog(log));
158
+ aggregator.setEntryGc((entry) =>
159
+ entry.has('addresses') ? entry.update('addresses', prune) : entry
160
+ );
161
+
162
+ aggregator.sorters.addressCount15m = addressCount15m;
163
+ aggregator.sorters.addressCount24h = addressCount24h;
164
+
165
+ pipeline
166
+ .getNode('main')
167
+ .map((log) => aggregator.processLog(log), 'aggregator');
136
168
 
137
169
  api.registerAggregator('signatures', aggregator);
138
170
  }
@@ -140,4 +172,7 @@ function start() {
140
172
  module.exports = {
141
173
  init,
142
174
  start,
175
+ get aggregator() {
176
+ return _aggregator;
177
+ },
143
178
  };
@@ -1,6 +1,10 @@
1
1
  const aggregator = require('../lib/aggregator');
2
2
 
3
- const sparkline = (entry, key) => {
3
+ const sparkline = (entry, key, output) => {
4
+ if (output === 'text') {
5
+ return;
6
+ }
7
+
4
8
  const id = entry.get('id');
5
9
 
6
10
  const points = entry
@@ -22,8 +26,8 @@ sparkline ('${id}', ${JSON.stringify(points)}, '#797979', 14, 5);
22
26
  };
23
27
 
24
28
  function init() {
25
- aggregator.defaultFormatter.insertFormat('activity', (entry) =>
26
- sparkline(entry, 'per_minute')
29
+ aggregator.defaultFormatter.insertFormat('activity', (entry, output) =>
30
+ sparkline(entry, 'per_minute', output)
27
31
  );
28
32
  }
29
33
 
@@ -23,10 +23,15 @@ function mapper(entry, format) {
23
23
  }
24
24
 
25
25
  function start() {
26
- api.get('/status(.:format(txt|json))?', (req, res) => {
26
+ api.get('/status{.:format}', (req, res) => {
27
27
  const raw = req.query.raw ? true : false;
28
28
  const format = req.params.format || (raw ? 'json' : null);
29
29
 
30
+ if (format && !['json', 'txt'].includes(format)) {
31
+ res.sendStatus(404);
32
+ return;
33
+ }
34
+
30
35
  let rawData = monitoring.getAllComputed();
31
36
 
32
37
  if (req.query.type) {