@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.
- package/README.md +9 -4
- package/config/apache_syslog_example.js +1 -1
- package/config/default.js +1 -1
- package/config/example.js +4 -4
- package/config/express_websocket_example.js +1 -1
- 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 +16 -14
- package/scripts/fetch-anthropic-ips.js +60 -0
- package/src/app/api.js +129 -3
- package/src/app/index.js +8 -4
- package/src/app/mount.js +115 -0
- package/src/app/websocket.js +12 -9
- package/src/app/ws-server.js +123 -0
- package/src/constants.js +8 -5
- package/src/data/amazon-searchbot-ips.json +304 -0
- package/src/data/amazonbot-ips.json +775 -1
- package/src/data/chatgpt-user-ips.json +115 -112
- package/src/data/claude-bot-ips.json +28 -0
- package/src/data/cloudfront-ips.json +14 -0
- package/src/data/gptbot-ips.json +0 -3
- package/src/data/openai-searchbot-ips.json +4 -0
- package/src/input/http.js +4 -0
- package/src/input/syslog.js +5 -1
- package/src/input/websocket.js +2 -2
- package/src/lib/aggregator.js +112 -19
- package/src/lib/formatter.js +10 -1
- package/src/lib/log-buffer.js +45 -0
- package/src/lib/persistence.js +11 -1
- package/src/lib/pipeline.js +122 -11
- package/src/lib/recent-map.js +23 -0
- package/src/modules/address.js +59 -2
- package/src/modules/dnsbl.js +11 -1
- package/src/modules/history.js +4 -28
- package/src/modules/identity.js +57 -8
- package/src/modules/index.js +14 -5
- package/src/modules/signature.js +49 -14
- package/src/modules/sparkline.js +7 -3
- package/src/modules/status.js +6 -1
package/src/modules/history.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/modules/identity.js
CHANGED
|
@@ -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
|
|
498
|
+
pipeline
|
|
499
|
+
.getNode('main')
|
|
500
|
+
.map((log) => aggregator.processLog(log), 'aggregator');
|
|
452
501
|
|
|
453
502
|
api.registerAggregator('identities', aggregator);
|
|
454
503
|
}
|
package/src/modules/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/modules/signature.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
const { is
|
|
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
|
-
['
|
|
84
|
+
['addressCount15m', addressCount15m],
|
|
85
|
+
['addressCount24h', addressCount24h],
|
|
79
86
|
[
|
|
80
87
|
'addresses',
|
|
81
88
|
(entry) =>
|
|
82
|
-
entry
|
|
83
|
-
.get('addresses')
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/modules/sparkline.js
CHANGED
|
@@ -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
|
|
package/src/modules/status.js
CHANGED
|
@@ -23,10 +23,15 @@ function mapper(entry, format) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function start() {
|
|
26
|
-
api.get('/status
|
|
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) {
|