@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,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 };
@@ -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 };
@@ -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
  };