@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
@@ -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');
@@ -256,12 +318,61 @@ class Pipeline extends Builder {
256
318
  }
257
319
 
258
320
  registerNode(name, node) {
321
+ const previous = this.nodes[name];
322
+ if (previous && previous !== node && previous.name === name) {
323
+ previous.name = null;
324
+ }
259
325
  this.nodes[name] = node;
260
326
  }
261
327
 
262
328
  getNode(name) {
263
329
  return this.nodes[name];
264
330
  }
331
+
332
+ getTree(node = this) {
333
+ const second = Math.floor(Date.now() / 1000);
334
+ const windowStart = second - node.rateBuckets.length + 1;
335
+ const rateCount = node.rateBuckets.reduce(
336
+ (count, bucket) =>
337
+ bucket.second >= windowStart && bucket.second <= second
338
+ ? count + bucket.count
339
+ : count,
340
+ 0
341
+ );
342
+ const obj = {
343
+ name: node.name || null,
344
+ op: node.op || null,
345
+ module: node.module || null,
346
+ fnName: node.fnName || null,
347
+ label: node._label || null,
348
+ count: node.counter,
349
+ rate: rateCount / node.rateBuckets.length,
350
+ children: node.children.map((child) => this.getTree(child)),
351
+ };
352
+ // Include inputs at the root level
353
+ if (node === this) {
354
+ obj.inputs = this.inputs.map((input) => {
355
+ const monitor = this.monitors.find(
356
+ (m) => m.type === 'input' && m.name === input.name
357
+ );
358
+ const accepted = monitor
359
+ ? monitor.speeds.accepted.per_minute.compute()
360
+ : null;
361
+ const rejected = monitor
362
+ ? monitor.speeds.rejected.per_minute.compute()
363
+ : null;
364
+ return {
365
+ name: input.name,
366
+ node: input._tap ? input._tap.name : null,
367
+ tree: input._tap ? this.getTree(input._tap) : null,
368
+ status: monitor ? monitor.status : null,
369
+ accepted: accepted ? accepted.reduce((a, b) => a + b, 0) : 0,
370
+ rejected: rejected ? rejected.reduce((a, b) => a + b, 0) : 0,
371
+ };
372
+ });
373
+ }
374
+ return obj;
375
+ }
265
376
  }
266
377
 
267
378
  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 };
package/src/lib/speed.js CHANGED
@@ -10,6 +10,7 @@ class Speed {
10
10
  this.windowSize = windowSize;
11
11
  this.size = size;
12
12
  this.counters = Map();
13
+ this.sums = Map();
13
14
  this.started = null;
14
15
  this.latest = null;
15
16
  }
@@ -18,23 +19,26 @@ class Speed {
18
19
  gc() {
19
20
  const cutoff = now() - this.size * this.windowSize;
20
21
  this.counters = this.counters.filter((c, t) => parseInt(t) > cutoff);
22
+ this.sums = this.sums.filter((c, t) => parseInt(t) > cutoff);
21
23
  }
22
24
 
23
- hit(time = now()) {
25
+ hit(time = now(), value) {
24
26
  this.started = !this.started ? time : Math.min(this.started, time);
25
27
  this.latest = !this.latest ? time : Math.max(this.latest, time);
26
28
  const idx = time - (time % this.windowSize);
27
29
  this.counters = this.counters.update(`${idx}`, 0, (n) => n + 1);
30
+ if (value !== undefined) {
31
+ this.sums = this.sums.update(`${idx}`, 0, (n) => n + value);
32
+ }
28
33
  this.gc();
29
34
  return this;
30
35
  }
31
36
 
32
- compute() {
37
+ compute(time = now()) {
33
38
  this.gc();
34
39
  if (!this.started) {
35
40
  return List();
36
41
  }
37
- const time = now();
38
42
  return Range(0, this.size)
39
43
  .map((n) => {
40
44
  let t = time - n * this.windowSize;
@@ -45,6 +49,42 @@ class Speed {
45
49
  })
46
50
  .filter((v) => v !== undefined);
47
51
  }
52
+
53
+ computeSum(time = now()) {
54
+ this.gc();
55
+ if (!this.started) {
56
+ return List();
57
+ }
58
+ return Range(0, this.size)
59
+ .map((n) => {
60
+ let t = time - n * this.windowSize;
61
+ t = t - (t % this.windowSize);
62
+ if (t >= this.started - (this.started % this.windowSize)) {
63
+ return this.sums.get(`${t}`, 0);
64
+ }
65
+ })
66
+ .filter((v) => v !== undefined);
67
+ }
68
+
69
+ toJSON() {
70
+ return {
71
+ windowSize: this.windowSize,
72
+ size: this.size,
73
+ counters: this.counters.toObject(),
74
+ sums: this.sums.toObject(),
75
+ started: this.started,
76
+ latest: this.latest,
77
+ };
78
+ }
79
+
80
+ static fromJSON(data) {
81
+ const speed = new Speed(data.windowSize, data.size);
82
+ speed.counters = Map(data.counters);
83
+ speed.sums = Map(data.sums);
84
+ speed.started = data.started;
85
+ speed.latest = data.latest;
86
+ return speed;
87
+ }
48
88
  }
49
89
 
50
90
  module.exports = {
package/src/lib/util.js CHANGED
@@ -34,12 +34,28 @@ exports.createLog = (req, res) => {
34
34
  });
35
35
  };
36
36
 
37
- exports.aggregateSpeed = (entry, key) =>
37
+ exports.aggregateCount = (entry, key) =>
38
38
  entry
39
39
  .getIn(['speed', key])
40
40
  .compute()
41
41
  .reduce((p, c) => p + c, 0);
42
42
 
43
+ exports.aggregateSum = (entry, key) =>
44
+ entry
45
+ .getIn(['speed', key])
46
+ .computeSum()
47
+ .reduce((p, c) => p + c, 0);
48
+
49
+ exports.formatDuration = (ms) => {
50
+ const totalSeconds = ms / 1000;
51
+ const minutes = Math.floor(totalSeconds / 60);
52
+ const seconds = Math.round(totalSeconds % 60);
53
+ if (minutes > 0) {
54
+ return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
55
+ }
56
+ return `${totalSeconds.toFixed(1)}s`;
57
+ };
58
+
43
59
  exports.formatTable = (data) => {
44
60
  if (!data || data.length === 0) {
45
61
  return '';
@@ -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
  };
@@ -70,7 +70,7 @@ function init() {
70
70
  pipeline.getNode('main').map(augment).registerNode('main');
71
71
 
72
72
  aggregator.defaultFormatter.insertFormat('agent', agentFormat, {
73
- before: '15m',
73
+ before: 'count15m',
74
74
  color: 'grey',
75
75
  });
76
76
  aggregator.defaultFormatter.insertFormat('os', osFormat, {
@@ -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
  };
@@ -0,0 +1,62 @@
1
+ const { api } = require('../app');
2
+ const constants = require('../constants');
3
+ const LogBuffer = require('../lib/log-buffer');
4
+ const persistence = require('../lib/persistence');
5
+ const pipeline = require('../lib/pipeline');
6
+
7
+ function start() {
8
+ const capacity =
9
+ (constants.modules.history && constants.modules.history.capacity) || 1000;
10
+ const buffers = {};
11
+
12
+ function registerNodeHistory(name, node) {
13
+ const buffer = new LogBuffer(capacity);
14
+ buffers[name] = buffer;
15
+ persistence.register(`history-${persistence.safeName(name)}`, buffer);
16
+
17
+ node.map((log) => {
18
+ buffer.push(log);
19
+ return log;
20
+ });
21
+
22
+ api.get(`/history/${name}.json`, (req, res) => {
23
+ const { identity, signature, address } = req.query;
24
+ const limit = parseInt(req.query.limit, 10) || 100;
25
+
26
+ let logs = buffer.toArray();
27
+
28
+ if (identity) {
29
+ logs = logs.filter((log) => log.get('identity') === identity);
30
+ }
31
+ if (signature) {
32
+ logs = logs.filter(
33
+ (log) => log.getIn(['signature', 'id']) === signature
34
+ );
35
+ }
36
+ if (address) {
37
+ logs = logs.filter(
38
+ (log) => log.getIn(['address', 'value']) === address
39
+ );
40
+ }
41
+
42
+ logs = logs.slice(0, limit);
43
+
44
+ res.json(logs);
45
+ });
46
+ }
47
+
48
+ for (const [name, node] of Object.entries(pipeline.nodes)) {
49
+ registerNodeHistory(name, node);
50
+ }
51
+
52
+ // Auto-register future nodes
53
+ const originalRegisterNode = pipeline.registerNode.bind(pipeline);
54
+ pipeline.registerNode = function (name, node) {
55
+ originalRegisterNode(name, node);
56
+ if (!buffers[name]) {
57
+ registerNodeHistory(name, node);
58
+ }
59
+ };
60
+ }
61
+
62
+ module.exports = { start };