@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
@@ -128,35 +128,79 @@ class Builder {
128
128
  this.children = [];
129
129
  this.name = null;
130
130
  this.pipeline = pipeline;
131
+ this.parent = null;
132
+ this.op = null;
133
+ this.module = null;
134
+ this.fnName = null;
135
+ this._label = null;
136
+ // Lightweight per-node counter for throughput tracking
137
+ this.counter = 0;
138
+ // Fixed one-second buckets keep rate tracking bounded and O(1) per event.
139
+ this.rateBuckets = Array.from({ length: 10 }, () => ({
140
+ second: null,
141
+ count: 0,
142
+ }));
131
143
  }
132
144
 
133
- add(xf) {
145
+ add(xf, { op = null, fnName = null, label = null } = {}) {
134
146
  const child = new Builder(xf, this.pipeline);
147
+ child.parent = this;
148
+ child.op = op;
149
+ child.module = this.pipeline ? this.pipeline.currentModule : null;
150
+ child.fnName = fnName || null;
151
+ child._label = label || null;
135
152
  this.children.push(child);
136
153
  return child;
137
154
  }
138
155
 
139
- map(f) {
140
- return this.add(map(f));
156
+ map(f, label) {
157
+ return this.add(map(f), { op: 'map', fnName: f.name, label });
141
158
  }
142
159
 
143
- filter(pred) {
144
- return this.add(filter(pred));
160
+ filter(pred, label) {
161
+ return this.add(filter(pred), { op: 'filter', fnName: pred.name, label });
145
162
  }
146
163
 
147
- split(pred) {
148
- return [this.add(filter(pred)), this.add(filter(complement(pred)))];
164
+ split(pred, labels) {
165
+ return [
166
+ this.add(filter(pred), {
167
+ op: 'split-true',
168
+ fnName: pred.name,
169
+ label: labels && labels[0],
170
+ }),
171
+ this.add(filter(complement(pred)), {
172
+ op: 'split-false',
173
+ fnName: pred.name,
174
+ label: labels && labels[1],
175
+ }),
176
+ ];
149
177
  }
150
178
 
151
- by(f) {
152
- return this.add(by(f));
179
+ by(f, label) {
180
+ return this.add(by(f), { op: 'by', fnName: f.name, label });
153
181
  }
154
182
 
155
183
  create() {
184
+ const self = this;
185
+ const countXf = (stream) => (event) => {
186
+ self.counter++;
187
+ const second = Math.floor(Date.now() / 1000);
188
+ const bucket = self.rateBuckets[second % self.rateBuckets.length];
189
+ if (bucket.second !== second) {
190
+ bucket.second = second;
191
+ bucket.count = 0;
192
+ }
193
+ bucket.count++;
194
+ forward(stream, event);
195
+ };
156
196
  if (this.children.length === 0) {
157
- return this.xf;
197
+ return comp([this.xf, countXf]);
158
198
  }
159
- return comp([this.xf, multiplex(this.children.map((b) => b.create()))]);
199
+ return comp([
200
+ this.xf,
201
+ countXf,
202
+ multiplex(this.children.map((b) => b.create())),
203
+ ]);
160
204
  }
161
205
 
162
206
  // Helpers
@@ -194,6 +238,9 @@ class Pipeline extends Builder {
194
238
  main: this,
195
239
  };
196
240
  this.pipeline = this;
241
+ this.name = 'raw';
242
+ this.currentModule = null;
243
+ this.op = 'identity';
197
244
  }
198
245
 
199
246
  registerInput(input) {
@@ -204,6 +251,16 @@ class Pipeline extends Builder {
204
251
  type: 'input',
205
252
  });
206
253
  this.monitors.push(monitor);
254
+ // Create a tap node so this input gets its own /logs/ WebSocket endpoint
255
+ const inputIndex = this.inputs.length;
256
+ const nodeName = `input-${inputIndex}`;
257
+ const tap = new Builder(identity(), this);
258
+ tap.name = nodeName;
259
+ tap.op = 'input';
260
+ tap.module = null;
261
+ tap.fnName = null;
262
+ this.registerNode(nodeName, tap);
263
+ input._tap = tap;
207
264
  }
208
265
 
209
266
  start() {
@@ -213,6 +270,8 @@ class Pipeline extends Builder {
213
270
  const monitor = this.monitors.find(
214
271
  (monitor) => monitor.type === 'input' && monitor.name === input.name
215
272
  );
273
+ // Build the tap stream so it can forward logs to WebSocket clients
274
+ const tapStream = input._tap ? input._tap.create()(() => {}) : null;
216
275
  input.start({
217
276
  success: (log) => {
218
277
  const valid = validate(log.toJS());
@@ -224,6 +283,9 @@ class Pipeline extends Builder {
224
283
  data: log,
225
284
  });
226
285
  monitor.hit('accepted');
286
+ if (tapStream) {
287
+ forward(tapStream, event);
288
+ }
227
289
  forward(stream, event);
228
290
  } else {
229
291
  monitor.hit('rejected');
@@ -249,19 +311,83 @@ class Pipeline extends Builder {
249
311
  });
250
312
  }
251
313
 
252
- stop() {
253
- return Promise.all(
254
- this.inputs.filter((input) => input.stop).map((input) => input.stop())
314
+ // Stops every input, even if some of them throw or reject, then rejects
315
+ // with their errors if any failed
316
+ async stop() {
317
+ const results = await Promise.allSettled(
318
+ this.inputs
319
+ .filter((input) => input.stop)
320
+ .map((input) => Promise.resolve().then(() => input.stop()))
255
321
  );
322
+ const errors = results
323
+ .filter((result) => result.status === 'rejected')
324
+ .map((result) => result.reason);
325
+ if (errors.length > 0) {
326
+ throw new AggregateError(
327
+ errors,
328
+ `${errors.length} input(s) failed to stop: ${errors
329
+ .map((err) => err.message)
330
+ .join('; ')}`
331
+ );
332
+ }
256
333
  }
257
334
 
258
335
  registerNode(name, node) {
336
+ const previous = this.nodes[name];
337
+ if (previous && previous !== node && previous.name === name) {
338
+ previous.name = null;
339
+ }
259
340
  this.nodes[name] = node;
260
341
  }
261
342
 
262
343
  getNode(name) {
263
344
  return this.nodes[name];
264
345
  }
346
+
347
+ getTree(node = this) {
348
+ const second = Math.floor(Date.now() / 1000);
349
+ const windowStart = second - node.rateBuckets.length + 1;
350
+ const rateCount = node.rateBuckets.reduce(
351
+ (count, bucket) =>
352
+ bucket.second >= windowStart && bucket.second <= second
353
+ ? count + bucket.count
354
+ : count,
355
+ 0
356
+ );
357
+ const obj = {
358
+ name: node.name || null,
359
+ op: node.op || null,
360
+ module: node.module || null,
361
+ fnName: node.fnName || null,
362
+ label: node._label || null,
363
+ count: node.counter,
364
+ rate: rateCount / node.rateBuckets.length,
365
+ children: node.children.map((child) => this.getTree(child)),
366
+ };
367
+ // Include inputs at the root level
368
+ if (node === this) {
369
+ obj.inputs = this.inputs.map((input) => {
370
+ const monitor = this.monitors.find(
371
+ (m) => m.type === 'input' && m.name === input.name
372
+ );
373
+ const accepted = monitor
374
+ ? monitor.speeds.accepted.per_minute.compute()
375
+ : null;
376
+ const rejected = monitor
377
+ ? monitor.speeds.rejected.per_minute.compute()
378
+ : null;
379
+ return {
380
+ name: input.name,
381
+ node: input._tap ? input._tap.name : null,
382
+ tree: input._tap ? this.getTree(input._tap) : null,
383
+ status: monitor ? monitor.status : null,
384
+ accepted: accepted ? accepted.reduce((a, b) => a + b, 0) : 0,
385
+ rejected: rejected ? rejected.reduce((a, b) => a + b, 0) : 0,
386
+ };
387
+ });
388
+ }
389
+ return obj;
390
+ }
265
391
  }
266
392
 
267
393
  module.exports = new Pipeline();
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Track distinct keys with the time they were last seen, bounded to a
3
+ * rolling window. Backs the addressCount15m/24h and signatureCount15m/24h
4
+ * counters.
5
+ */
6
+ const { Map } = require('immutable');
7
+
8
+ const { now } = require('./util');
9
+
10
+ // Rolling window in seconds
11
+ const WINDOW = 24 * 3600;
12
+
13
+ // Record `key` as seen at `time` in an Immutable Map<key, unix seconds>
14
+ const touch = (map = Map(), key, time = now()) => map.set(key, time);
15
+
16
+ // Drop keys not seen in the last WINDOW seconds
17
+ const prune = (map, time = now()) => map.filter((seen) => seen > time - WINDOW);
18
+
19
+ // Number of keys seen in the last `window` seconds
20
+ const countRecent = (map, window = WINDOW, time = now()) =>
21
+ map ? map.count((seen) => seen > time - window) : 0;
22
+
23
+ module.exports = { WINDOW, touch, prune, countRecent };
@@ -1,9 +1,18 @@
1
+ const { is } = require('immutable');
2
+
1
3
  const api = require('../app/api');
2
4
  const { Aggregator } = require('../lib/aggregator');
3
5
  const pipeline = require('../lib/pipeline');
6
+ const { touch, prune, countRecent } = require('../lib/recent-map');
4
7
 
5
8
  const identifier = (log) => log.getIn(['address', 'value']);
6
9
 
10
+ const signatureCount15m = (entry) =>
11
+ countRecent(entry.get('signatures'), 15 * 60);
12
+ const signatureCount24h = (entry) => countRecent(entry.get('signatures'));
13
+
14
+ let aggregator;
15
+
7
16
  function fill(log) {
8
17
  if (!log.hasIn(['address', 'value'])) {
9
18
  log = log.setIn(['address', 'value'], log.getIn(['request', 'address']));
@@ -16,11 +25,56 @@ function init() {
16
25
  }
17
26
 
18
27
  function start() {
19
- const aggregator = new Aggregator();
28
+ aggregator = new Aggregator();
20
29
 
21
30
  aggregator.setIdentifier(identifier);
22
31
 
23
- pipeline.getNode('main').map((log) => aggregator.processLog(log));
32
+ const enricher = (entry, log) => {
33
+ for (const field of [
34
+ 'address',
35
+ 'identity',
36
+ 'cloudflare',
37
+ 'dnsbl',
38
+ 'geoip',
39
+ 'hostname',
40
+ 'agent',
41
+ 'language',
42
+ 'signature',
43
+ ]) {
44
+ if (log.has(field) && !is(log.get(field), entry.get(field))) {
45
+ entry = entry.set(field, log.get(field));
46
+ }
47
+ }
48
+
49
+ // Distinct signature IDs with last-seen time, pruned to 24h; backs both
50
+ // the 15m and 24h counts
51
+ const signatureId = log.getIn(['signature', 'id']);
52
+ if (signatureId) {
53
+ entry = entry.update('signatures', (map) => touch(map, signatureId));
54
+ }
55
+
56
+ return entry;
57
+ };
58
+
59
+ aggregator.setEnricher(enricher);
60
+
61
+ aggregator.setEntryGc((entry) =>
62
+ entry.has('signatures') ? entry.update('signatures', prune) : entry
63
+ );
64
+
65
+ aggregator.formatter.insertFormat('signatureCount15m', signatureCount15m, {
66
+ before: 'count15m',
67
+ });
68
+ aggregator.formatter.insertFormat('signatureCount24h', signatureCount24h, {
69
+ before: 'count15m',
70
+ });
71
+
72
+ aggregator.sorters.signatureCount15m = signatureCount15m;
73
+ aggregator.sorters.signatureCount24h = signatureCount24h;
74
+
75
+ pipeline
76
+ .getNode('main')
77
+ .map((log) => aggregator.processLog(log), 'aggregator');
24
78
 
25
79
  api.registerAggregator('addresses', aggregator);
26
80
  }
@@ -28,4 +82,7 @@ function start() {
28
82
  module.exports = {
29
83
  init,
30
84
  start,
85
+ get aggregator() {
86
+ return aggregator;
87
+ },
31
88
  };
@@ -4,11 +4,19 @@ const aggregator = require('../lib/aggregator');
4
4
  const cache = require('../lib/cache');
5
5
  const pipeline = require('../lib/pipeline');
6
6
 
7
+ // Indirection so tests can swap the DNS lookup without hitting the network.
8
+ const defaultLookup = (ip, blacklist) => dnsbl.lookup(ip, blacklist);
9
+ let lookup = defaultLookup;
10
+
11
+ function setLookup(fn = defaultLookup) {
12
+ lookup = fn;
13
+ }
14
+
7
15
  async function xblLookup(ip) {
8
16
  if (await cache.has(`xbl-${ip}`)) {
9
17
  return cache.get(`xbl-${ip}`);
10
18
  }
11
- const result = await dnsbl.lookup(ip, 'xbl.spamhaus.org');
19
+ const result = await lookup(ip, 'xbl.spamhaus.org');
12
20
  cache.set(`xbl-${ip}`, result);
13
21
  return result;
14
22
  }
@@ -45,4 +53,6 @@ function init() {
45
53
  module.exports = {
46
54
  augment,
47
55
  init,
56
+ setLookup,
57
+ xblFormat,
48
58
  };
@@ -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);
@@ -1,4 +1,5 @@
1
1
  const dns = require('dns').promises;
2
+ const net = require('net');
2
3
 
3
4
  const debug = require('debug')('hyperwatch:hostname');
4
5
 
@@ -13,6 +14,12 @@ function ignoreError() {
13
14
  return null;
14
15
  }
15
16
 
17
+ // Compare addresses in canonical form: DNS and logs may write the same IPv6
18
+ // address differently (zero compression, case)
19
+ function canonical(ip) {
20
+ return net.isIPv6(ip) ? new URL(`http://[${ip}]`).hostname : ip;
21
+ }
22
+
16
23
  function isValid(hostname) {
17
24
  return (
18
25
  hostname &&
@@ -40,11 +47,12 @@ async function lookup(ip, { fast = false } = {}) {
40
47
  if (isValid(reverse)) {
41
48
  entry.value = reverse;
42
49
  debug(`Resolve ${reverse} ...`);
43
- const reverseIps = await dns.resolve(reverse).catch(ignoreError);
50
+ const reverseIps = await (
51
+ net.isIPv6(ip) ? dns.resolve6(reverse) : dns.resolve4(reverse)
52
+ ).catch(ignoreError);
44
53
  if (reverseIps) {
45
- const reverseIp = reverseIps[0];
46
- debug(`Resolve ${reverse}: ${reverseIp}`);
47
- if (reverseIp === ip) {
54
+ debug(`Resolve ${reverse}: ${reverseIps.join(', ')}`);
55
+ if (reverseIps.map(canonical).includes(canonical(ip))) {
48
56
  entry.verified = true;
49
57
  }
50
58
  } else {
@@ -3,21 +3,34 @@ 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']);
25
+ // Reverse DNS confirmed by a forward lookup back to the same address. The
26
+ // older identities below still use the unconfirmed hostname.
27
+ const verifiedHostname = log.getIn(['hostname', 'verified'])
28
+ ? hostname
29
+ : undefined;
18
30
  const address =
19
31
  log.getIn(['address', 'value']) || log.getIn(['request', 'address']);
20
32
  const signature = log.getIn(['signature', 'id']);
33
+ const signatureAgent = log.getIn(['request', 'headers', 'signature-agent']);
21
34
 
22
35
  switch (family) {
23
36
  // Per hostname
@@ -54,8 +67,23 @@ function augment(log) {
54
67
  return hostname && hostname.endsWith('.yandex.com')
55
68
  ? log.set('identity', 'Yandex')
56
69
  : log;
70
+ // https://www.semrush.com/bot/ (SemrushBot-BA and SemrushBot-SI parse
71
+ // as SemrushBot)
57
72
  case 'SemrushBot':
58
- if (hostname && hostname.endsWith('.semrush.com')) {
73
+ case 'SemrushBot-SWA':
74
+ case 'SemrushBot-OCOB':
75
+ case 'SemrushBot-FT':
76
+ case 'SemrushBot-ESI':
77
+ case 'SiteAuditBot':
78
+ case 'SplitSignalBot':
79
+ case 'RyteBot':
80
+ // Some SemrushBot addresses have the generic PTR bot.semrush.com, which
81
+ // doesn't resolve back to them: accept Semrush's own range too
82
+ // (85.208.98.0/24, announced by AS209366)
83
+ if (
84
+ (verifiedHostname && verifiedHostname.endsWith('.semrush.com')) ||
85
+ (address && new IPCIDR('85.208.98.0/24').contains(address))
86
+ ) {
59
87
  return log.set('identity', 'Semrush');
60
88
  }
61
89
  break;
@@ -177,6 +205,16 @@ function augment(log) {
177
205
  return hostname && hostname.endsWith('.babbar.eu')
178
206
  ? log.set('identity', 'Babbar')
179
207
  : log;
208
+ case 'Reflectionbot':
209
+ // https://reflection.ai/bot
210
+ return verifiedHostname && verifiedHostname.endsWith('.reflection.ai')
211
+ ? log.set('identity', 'Reflection')
212
+ : log;
213
+ case 'SEOkicks':
214
+ // https://www.seokicks.de/robot.html
215
+ return verifiedHostname && verifiedHostname.endsWith('.seokicks.de')
216
+ ? log.set('identity', 'SEOkicks')
217
+ : log;
180
218
  case 'bnf.fr bot':
181
219
  return hostname && hostname.endsWith('.bnf.fr')
182
220
  ? log.set('identity', 'BnF.fr')
@@ -237,6 +275,22 @@ function augment(log) {
237
275
  return hostname && hostname.endsWith('.blex.seranking.com')
238
276
  ? log.set('identity', 'SE Ranking')
239
277
  : log;
278
+ case 'SofyaBot':
279
+ return hostname && hostname.endsWith('.sofya.co')
280
+ ? log.set('identity', 'Sofya')
281
+ : log;
282
+ case 'YouBot':
283
+ // https://docs.you.com/youbot
284
+ return hostname && hostname.endsWith('.search.you.com')
285
+ ? log.set('identity', 'You.com')
286
+ : log;
287
+ case 'AIWebIndex':
288
+ case 'AIWebIndex-Agent':
289
+ // Lyrenth AI-readable web index, forward-confirmed rDNS under lyrenth.com
290
+ // https://lyrenth.com/bot
291
+ return hostname && hostname.endsWith('.lyrenth.com')
292
+ ? log.set('identity', 'Lyrenth')
293
+ : log;
240
294
 
241
295
  // Per hostname + CIDR
242
296
  case 'Twitterbot':
@@ -276,6 +330,11 @@ function augment(log) {
276
330
  return address && new IPCIDR('203.133.160.0/19').contains(address)
277
331
  ? log.set('identity', family)
278
332
  : log;
333
+ case 'LinkupBot':
334
+ // https://www.linkup.so/linkupbot-ips.txt
335
+ return address && new IPCIDR('35.198.113.100/32').contains(address)
336
+ ? log.set('identity', 'Linkup')
337
+ : log;
279
338
  case 'OAI-SearchBot':
280
339
  return openaiSearchbotIps.some((cidr) =>
281
340
  new IPCIDR(cidr).contains(address)
@@ -291,11 +350,18 @@ function augment(log) {
291
350
  return chatgptUserIps.some((cidr) => new IPCIDR(cidr).contains(address))
292
351
  ? log.set('identity', 'ChatGPT')
293
352
  : log;
353
+ case 'ClaudeBot':
354
+ case 'Claude-User':
355
+ case 'Claude-SearchBot':
356
+ case 'Claude-Web':
357
+ case 'anthropic-ai':
358
+ // https://claude.com/crawling/bots.json
359
+ return address && claudeBotCidrs.some((cidr) => cidr.contains(address))
360
+ ? log.set('identity', 'Claude')
361
+ : log;
294
362
  case 'meta-externalagent':
295
363
  case 'meta-webindexer':
296
- return address &&
297
- (new IPCIDR('2a03:2880::/29').contains(address) ||
298
- new IPCIDR('2a06:98c0:3600::/48').contains(address))
364
+ return address && new IPCIDR('2a03:2880::/29').contains(address)
299
365
  ? log.set('identity', 'Meta')
300
366
  : log;
301
367
 
@@ -335,10 +401,6 @@ function augment(log) {
335
401
  hostname.endsWith('.eu-central-1.compute.amazonaws.com')
336
402
  ? log.set('identity', 'Wise')
337
403
  : log;
338
- case 'ClaudeBot':
339
- return hostname && hostname.endsWith('.us-east-2.compute.amazonaws.com')
340
- ? log.set('identity', 'Claude')
341
- : log;
342
404
  case 'PerplexityBot':
343
405
  return hostname && hostname.endsWith('.compute-1.amazonaws.com')
344
406
  ? log.set('identity', 'Perplexity')
@@ -365,6 +427,12 @@ function augment(log) {
365
427
  return hostname && hostname.endsWith('.googleusercontent.com')
366
428
  ? log.set('identity', 'Dolfe')
367
429
  : log;
430
+ case 'ShapBot':
431
+ // Parallel Web Systems crawler, runs on GCE
432
+ // https://docs.parallel.ai/resources/crawler
433
+ return hostname && hostname.endsWith('.googleusercontent.com')
434
+ ? log.set('identity', 'Parallel')
435
+ : log;
368
436
 
369
437
  // Hetzner
370
438
  case 'Ubermetrics':
@@ -391,6 +459,15 @@ function augment(log) {
391
459
  return hostname && hostname.endsWith('.flipboard.com')
392
460
  ? log.set('identity', 'Flipboard')
393
461
  : log;
462
+
463
+ // Web Bot Auth
464
+ case 'ExaSearchBot':
465
+ // Exa search crawler. No published IP ranges or reverse DNS: requests
466
+ // are signed (RFC 9421) and carry a `Signature-Agent` header
467
+ // https://crawler.exa.ai/
468
+ return signatureAgent && signatureAgent.includes('https://crawler.exa.ai')
469
+ ? log.set('identity', 'Exa')
470
+ : log;
394
471
  }
395
472
 
396
473
  // Hostname only
@@ -416,6 +493,10 @@ function augment(log) {
416
493
  if (hostname.endsWith('.qwant.com')) {
417
494
  return log.set('identity', 'Qwant');
418
495
  }
496
+ // Semrush crawlers not named above
497
+ if (verifiedHostname && verifiedHostname.endsWith('.semrush.com')) {
498
+ return log.set('identity', 'Semrush');
499
+ }
419
500
  }
420
501
 
421
502
  // Signature
@@ -448,7 +529,9 @@ function start() {
448
529
 
449
530
  aggregator.setIdentifier(identifier);
450
531
 
451
- pipeline.getNode('main').map((log) => aggregator.processLog(log));
532
+ pipeline
533
+ .getNode('main')
534
+ .map((log) => aggregator.processLog(log), 'aggregator');
452
535
 
453
536
  api.registerAggregator('identities', aggregator);
454
537
  }
@@ -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
  }