@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
@@ -0,0 +1,115 @@
1
+ const api = require('./api');
2
+ const wsServer = require('./ws-server');
3
+
4
+ // Servers where Hyperwatch handles WebSocket upgrades, with their listener
5
+ const upgradeListeners = new WeakMap();
6
+
7
+ // Paths where Hyperwatch is mounted, per app. Express can't remove routes, so
8
+ // a path stays mounted for the life of the app.
9
+ const mountedPaths = new WeakMap();
10
+
11
+ // A path as the app routes it: case-insensitive unless the app says otherwise
12
+ function routingKey(app, path) {
13
+ return app.enabled('case sensitive routing') ? path : path.toLowerCase();
14
+ }
15
+
16
+ function validate(app, { server, path, middleware, fallback }) {
17
+ if (typeof app !== 'function' || typeof app.use !== 'function') {
18
+ throw new TypeError('mount() expects an Express app');
19
+ }
20
+ if (!server || typeof server.on !== 'function') {
21
+ throw new TypeError(
22
+ 'mount() expects the HTTP server of the app: { server }'
23
+ );
24
+ }
25
+ if (typeof path !== 'string' || !path.startsWith('/') || path.endsWith('/')) {
26
+ throw new TypeError(
27
+ `Invalid mount path "${path}": it must start with "/" and not end with "/"`
28
+ );
29
+ }
30
+ const middlewares = middleware === undefined ? [] : [].concat(middleware);
31
+ if (!middlewares.every((fn) => typeof fn === 'function')) {
32
+ throw new TypeError(
33
+ 'middleware must be a function or an array of functions'
34
+ );
35
+ }
36
+ if (fallback !== undefined && typeof fallback !== 'function') {
37
+ throw new TypeError('fallback must be a function');
38
+ }
39
+ const paths = mountedPaths.get(app);
40
+ if (paths && paths.has(routingKey(app, path))) {
41
+ throw new Error(
42
+ `Hyperwatch is already mounted on this app at ${path}: its routes can't be removed, so mounting again can't change its options (e.g. middleware)`
43
+ );
44
+ }
45
+ if (upgradeListeners.has(server)) {
46
+ throw new Error('Hyperwatch is already mounted on this server');
47
+ }
48
+ return middlewares;
49
+ }
50
+
51
+ // Whether a request is under the mount path, matching case like Express
52
+ function isUnderPath(target, path, caseSensitive) {
53
+ const pathname = caseSensitive
54
+ ? target.pathname
55
+ : target.pathname.toLowerCase();
56
+ const mountPath = caseSensitive ? path : path.toLowerCase();
57
+ return pathname === mountPath || pathname.startsWith(`${mountPath}/`);
58
+ }
59
+
60
+ /**
61
+ * Mount Hyperwatch in an Express app, under an explicit path:
62
+ *
63
+ * hyperwatch.app.mount(app, { server, path: '/_hyperwatch', middleware: auth });
64
+ *
65
+ * - Registers `app.use(path, ...middleware, router)` where it's called, so the
66
+ * app's middleware order is kept.
67
+ * - Adds one 'upgrade' listener to `server`. WebSocket upgrades under `path`
68
+ * go through the app like HTTP requests, so `middleware` applies to both.
69
+ * Other upgrades go to `fallback` when given, or are left to the server's
70
+ * other listeners. Malformed targets get 400.
71
+ * - Never creates a server nor listens.
72
+ *
73
+ * Mounting again on the same server, or on the same app at the same path
74
+ * (even after detachUpgrades()), throws before registering anything.
75
+ * Returns `{ path, detachUpgrades }`: detachUpgrades() removes the upgrade
76
+ * listener and releases the server. Express can't remove routes, so the
77
+ * HTTP routes stay mounted on the app, with their original middleware.
78
+ */
79
+ function mount(app, options = {}) {
80
+ const middlewares = validate(app, options);
81
+ const { server, path, fallback } = options;
82
+
83
+ app.use(path, ...middlewares, api);
84
+ if (!mountedPaths.has(app)) {
85
+ mountedPaths.set(app, new Set());
86
+ }
87
+ mountedPaths.get(app).add(routingKey(app, path));
88
+
89
+ const listener = (req, socket, head) => {
90
+ const target = wsServer.parseTarget(req.url);
91
+ // Malformed targets are rejected before reaching the app or fallback
92
+ if (!target) {
93
+ return wsServer.reject(socket, 400, 'Bad Request');
94
+ }
95
+ if (isUnderPath(target, path, app.enabled('case sensitive routing'))) {
96
+ wsServer.dispatch(app, req, socket, head);
97
+ } else if (fallback) {
98
+ fallback(req, socket, head);
99
+ }
100
+ };
101
+ server.on('upgrade', listener);
102
+ upgradeListeners.set(server, listener);
103
+
104
+ return {
105
+ path,
106
+ detachUpgrades() {
107
+ if (upgradeListeners.get(server) === listener) {
108
+ server.off('upgrade', listener);
109
+ upgradeListeners.delete(server);
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ module.exports = mount;
@@ -1,13 +1,18 @@
1
- const express = require('express');
2
- const expressWs = require('express-ws');
3
- const uuid = require('uuid');
1
+ const crypto = require('crypto');
4
2
 
3
+ const constants = require('../constants');
5
4
  const monitoring = require('../lib/monitoring');
6
5
 
7
- const app = express();
8
- expressWs(app);
6
+ const wsServer = require('./ws-server');
9
7
 
10
- app.streamToWebsocket = (
8
+ /**
9
+ * Express middleware completing the Hyperwatch WebSocket upgrades dispatched
10
+ * by hyperwatch.app.mount(), which already includes it. Kept for apps that
11
+ * mounted it explicitly: on its own, it doesn't handle any upgrade.
12
+ */
13
+ const websocket = (req, res, next) => wsServer.middleware(req, res, next);
14
+
15
+ websocket.streamToWebsocket = (
11
16
  endpoint,
12
17
  stream,
13
18
  { name = `WebSocket: ${endpoint}`, monitoringEnabled = false } = {}
@@ -35,19 +40,28 @@ app.streamToWebsocket = (
35
40
  };
36
41
  updateMonitoringStatus();
37
42
 
38
- app.ws(endpoint, (client, req) => {
39
- const clientId = req.query.clientId || uuid.v4();
43
+ wsServer.ws(endpoint, (client, req) => {
44
+ const clientId = req.query.clientId || crypto.randomUUID();
40
45
  if (clients[clientId]) {
41
46
  console.log(`Client '${clientId}' is already connected. Terminating.`);
42
47
  client.terminate();
43
48
  return;
44
49
  }
45
50
  clients[clientId] = client;
51
+ client.isAlive = true;
46
52
  updateMonitoringStatus();
53
+ client.on('pong', () => {
54
+ client.isAlive = true;
55
+ });
47
56
  client.on('close', () => {
57
+ console.log(`Client '${clientId}' closed.`);
48
58
  delete clients[clientId];
49
59
  updateMonitoringStatus();
50
60
  });
61
+ client.on('error', (error) => {
62
+ console.log(`Client '${clientId}' error.`);
63
+ console.log(error);
64
+ });
51
65
  });
52
66
 
53
67
  stream.map((log) => {
@@ -59,7 +73,22 @@ app.streamToWebsocket = (
59
73
  client.send(JSON.stringify(log));
60
74
  }
61
75
  });
62
- });
76
+ }, `ws:${endpoint}`);
77
+
78
+ // Heartbeat: detect and clean up stale connections
79
+ setInterval(() => {
80
+ Object.entries(clients).forEach(([clientId, client]) => {
81
+ if (!client.isAlive) {
82
+ console.log(`Client '${clientId}' stale. Terminating.`);
83
+ client.terminate();
84
+ delete clients[clientId];
85
+ updateMonitoringStatus();
86
+ return;
87
+ }
88
+ client.isAlive = false;
89
+ client.ping();
90
+ });
91
+ }, constants.heartbeatInterval || 30000);
63
92
  };
64
93
 
65
- module.exports = app;
94
+ module.exports = websocket;
@@ -0,0 +1,123 @@
1
+ const http = require('http');
2
+
3
+ const { WebSocketServer } = require('ws');
4
+
5
+ const wss = new WebSocketServer({ noServer: true });
6
+
7
+ const routes = new Map();
8
+
9
+ // Marks an upgrade request dispatched through an Express app
10
+ const upgradeKey = Symbol('hyperwatch.upgrade');
11
+
12
+ function ws(path, handler) {
13
+ routes.set(path, handler);
14
+ }
15
+
16
+ /**
17
+ * Parse the target of an upgrade request without throwing. WebSocket
18
+ * upgrades use the origin form ("/path?query"): anything else is malformed.
19
+ * `pathname` is kept as sent, like Express routing does; `urlPathname` is
20
+ * normalized by the URL parser.
21
+ */
22
+ function parseTarget(url) {
23
+ if (typeof url !== 'string' || !url.startsWith('/')) {
24
+ return null;
25
+ }
26
+ try {
27
+ const { pathname: urlPathname, searchParams } = new URL(
28
+ url,
29
+ 'http://localhost'
30
+ );
31
+ return {
32
+ pathname: url.split(/[?#]/)[0],
33
+ urlPathname,
34
+ query: Object.fromEntries(searchParams),
35
+ };
36
+ } catch (err) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function reject(socket, status, message) {
42
+ socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
43
+ }
44
+
45
+ // Find the handler of a route, matching case like Express routing does
46
+ function findRoute(pathname, caseSensitive) {
47
+ if (caseSensitive) {
48
+ return routes.get(pathname);
49
+ }
50
+ const lowerCase = pathname.toLowerCase();
51
+ for (const [route, handler] of routes) {
52
+ if (route.toLowerCase() === lowerCase) {
53
+ return handler;
54
+ }
55
+ }
56
+ }
57
+
58
+ function handleUpgrade(request, socket, head) {
59
+ const target = parseTarget(request.url);
60
+ if (!target) {
61
+ return reject(socket, 400, 'Bad Request');
62
+ }
63
+ request.query = target.query;
64
+
65
+ // Standalone mode: exact match, stream names are case-sensitive
66
+ const handler = routes.get(target.urlPathname);
67
+ if (handler) {
68
+ wss.handleUpgrade(request, socket, head, (client) => {
69
+ handler(client, request);
70
+ });
71
+ } else {
72
+ socket.destroy();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Express middleware completing the WebSocket upgrades sent by dispatch().
78
+ * The route is matched on the path relative to where the middleware is
79
+ * mounted, so `app.use('/_hyperwatch', middleware)` serves `/_hyperwatch/logs/raw`.
80
+ */
81
+ function middleware(req, res, next) {
82
+ const upgrade = req[upgradeKey];
83
+ if (!upgrade) {
84
+ return next();
85
+ }
86
+ const handler = findRoute(
87
+ req.path,
88
+ req.app.enabled('case sensitive routing')
89
+ );
90
+ if (!handler) {
91
+ // The mount path is Hyperwatch's: unknown routes end here
92
+ return res.status(404).end();
93
+ }
94
+ req[upgradeKey] = null;
95
+ res.detachSocket(upgrade.socket);
96
+ wss.handleUpgrade(req, upgrade.socket, upgrade.head, (client) => {
97
+ handler(client, req);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Send a WebSocket upgrade through an Express app, like an HTTP request: it
103
+ * goes through the app's middlewares (e.g. authentication) and reaches the
104
+ * WebSocket middleware where it's mounted. The response is bound to the
105
+ * socket, so a middleware can reject the upgrade with an HTTP response, and
106
+ * Express answers errors and unknown routes as usual (e.g. 401, 503, 404).
107
+ */
108
+ function dispatch(app, req, socket, head) {
109
+ req[upgradeKey] = { socket, head };
110
+ const res = new http.ServerResponse(req);
111
+ res.assignSocket(socket);
112
+ res.on('finish', () => socket.end());
113
+ app(req, res);
114
+ }
115
+
116
+ module.exports = {
117
+ ws,
118
+ handleUpgrade,
119
+ middleware,
120
+ dispatch,
121
+ parseTarget,
122
+ reject,
123
+ };
package/src/constants.js CHANGED
@@ -11,6 +11,7 @@ const constants = {
11
11
  active: false,
12
12
  priority: 200,
13
13
  },
14
+ // --- Enrichment: independent modules (no dependencies) ---
14
15
  cloudflare: {
15
16
  active: false,
16
17
  priority: 500,
@@ -23,18 +24,19 @@ const constants = {
23
24
  active: false,
24
25
  priority: 501,
25
26
  },
26
- language: {
27
- active: false,
28
- priority: 503,
29
- },
30
27
  hostname: {
31
28
  active: false,
32
29
  priority: 502,
33
30
  },
31
+ language: {
32
+ active: false,
33
+ priority: 503,
34
+ },
34
35
  dnsbl: {
35
36
  active: false,
36
37
  priority: 503,
37
38
  },
39
+ // --- Classification: depends on enrichment above ---
38
40
  address: {
39
41
  active: false,
40
42
  priority: 600,
@@ -45,13 +47,23 @@ const constants = {
45
47
  },
46
48
  identity: {
47
49
  active: false,
48
- priority: 620,
50
+ priority: 620, // depends on: agent, hostname, signature, address
51
+ },
52
+ // --- Output: depends on full enrichment ---
53
+ history: {
54
+ active: false,
55
+ priority: 700,
49
56
  },
50
57
  sparkline: {
51
58
  active: false,
52
59
  priority: 800,
53
60
  },
54
61
  },
62
+ persistence: {
63
+ enabled: false,
64
+ path: null,
65
+ namespace: null,
66
+ },
55
67
  };
56
68
 
57
69
  module.exports = rc('hyperwatch', constants);