blockmine 1.25.0 → 1.27.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 (164) hide show
  1. package/CHANGELOG.md +46 -3
  2. package/backend/cli.js +1 -1
  3. package/backend/package.json +2 -2
  4. package/backend/prisma/migrations/20260328173000_add_plugin_source_ref/migration.sql +2 -0
  5. package/backend/prisma/migrations/migration_lock.toml +2 -2
  6. package/backend/prisma/schema.prisma +2 -0
  7. package/backend/src/api/routes/apiKeys.js +8 -0
  8. package/backend/src/api/routes/bots.js +258 -9
  9. package/backend/src/api/routes/eventGraphs.js +151 -1
  10. package/backend/src/api/routes/health.js +38 -0
  11. package/backend/src/api/routes/nodeRegistry.js +63 -0
  12. package/backend/src/api/routes/plugins.js +254 -29
  13. package/backend/src/container.js +11 -8
  14. package/backend/src/core/BotCommandLoader.js +161 -0
  15. package/backend/src/core/BotConnection.js +125 -0
  16. package/backend/src/core/BotEventHandlers.js +234 -0
  17. package/backend/src/core/BotIPCHandler.js +445 -0
  18. package/backend/src/core/BotManager.js +15 -7
  19. package/backend/src/core/BotProcess.js +75 -142
  20. package/backend/src/core/EventGraphManager.js +7 -3
  21. package/backend/src/core/GraphDebugHandler.js +229 -0
  22. package/backend/src/core/GraphDebugIPC.js +117 -0
  23. package/backend/src/core/GraphExecutionEngine.js +545 -978
  24. package/backend/src/core/GraphTraversal.js +80 -0
  25. package/backend/src/core/GraphValidation.js +73 -0
  26. package/backend/src/core/NodeDefinition.js +138 -0
  27. package/backend/src/core/NodeRegistry.js +153 -141
  28. package/backend/src/core/PluginManager.js +272 -31
  29. package/backend/src/core/RewindSignal.js +9 -0
  30. package/backend/src/core/config/ConfigValidator.js +72 -0
  31. package/backend/src/core/config/FeatureFlags.js +52 -0
  32. package/backend/src/core/config/__tests__/ConfigValidator.test.js +232 -0
  33. package/backend/src/core/domain/entities/Bot.js +39 -0
  34. package/backend/src/core/domain/entities/Command.js +41 -0
  35. package/backend/src/core/domain/entities/EventGraph.js +39 -0
  36. package/backend/src/core/domain/entities/Plugin.js +45 -0
  37. package/backend/src/core/domain/entities/User.js +40 -0
  38. package/backend/src/core/domain/services/DependencyResolver.js +168 -0
  39. package/backend/src/core/domain/services/GraphValidator.js +117 -0
  40. package/backend/src/core/domain/services/PermissionChecker.js +34 -0
  41. package/backend/src/core/domain/services/__tests__/DependencyResolver.test.js +126 -0
  42. package/backend/src/core/domain/valueObjects/BotConfig.js +27 -0
  43. package/backend/src/core/domain/valueObjects/DependencyGraph.js +86 -0
  44. package/backend/src/core/domain/valueObjects/PluginManifest.js +36 -0
  45. package/backend/src/core/errors/BaseError.js +29 -0
  46. package/backend/src/core/errors/ErrorHandler.js +81 -0
  47. package/backend/src/core/errors/__tests__/ErrorHandler.test.js +188 -0
  48. package/backend/src/core/errors/index.js +68 -0
  49. package/backend/src/core/infrastructure/BatchingUtility.js +66 -0
  50. package/backend/src/core/infrastructure/CircuitBreaker.js +103 -0
  51. package/backend/src/core/infrastructure/ConnectionPool.js +81 -0
  52. package/backend/src/core/infrastructure/RateLimiter.js +64 -0
  53. package/backend/src/core/infrastructure/__tests__/BatchingUtility.test.js +86 -0
  54. package/backend/src/core/infrastructure/__tests__/CircuitBreaker.test.js +156 -0
  55. package/backend/src/core/infrastructure/__tests__/ConnectionPool.test.js +146 -0
  56. package/backend/src/core/infrastructure/__tests__/RateLimiter.test.js +171 -0
  57. package/backend/src/core/ipc/botApiFactory.js +72 -0
  58. package/backend/src/core/ipc/ipcMessageTypes.js +115 -0
  59. package/backend/src/core/logging/AuditLogger.js +61 -0
  60. package/backend/src/core/logging/StructuredLogger.js +80 -0
  61. package/backend/src/core/logging/__tests__/StructuredLogger.test.js +213 -0
  62. package/backend/src/core/logging/index.js +7 -0
  63. package/backend/src/core/metrics/MetricsCollector.js +104 -0
  64. package/backend/src/core/metrics/__tests__/MetricsCollector.test.js +131 -0
  65. package/backend/src/core/node-registries/actionsNodes.js +191 -0
  66. package/backend/src/core/node-registries/arraysNodes.js +152 -0
  67. package/backend/src/core/node-registries/botNodes.js +48 -0
  68. package/backend/src/core/node-registries/containerNodes.js +141 -0
  69. package/backend/src/core/node-registries/dataNodes.js +284 -0
  70. package/backend/src/core/node-registries/debugNodes.js +23 -0
  71. package/backend/src/core/node-registries/eventsNodes.js +223 -0
  72. package/backend/src/core/node-registries/flowNodes.js +151 -0
  73. package/backend/src/core/node-registries/furnaceNodes.js +123 -0
  74. package/backend/src/core/node-registries/index.js +108 -0
  75. package/backend/src/core/node-registries/inventory.js +102 -106
  76. package/backend/src/core/node-registries/logicNodes.js +54 -0
  77. package/backend/src/core/node-registries/mathNodes.js +38 -0
  78. package/backend/src/core/node-registries/navigationNodes.js +109 -0
  79. package/backend/src/core/node-registries/objectsNodes.js +90 -0
  80. package/backend/src/core/node-registries/stringsNodes.js +165 -0
  81. package/backend/src/core/node-registries/timeNodes.js +105 -0
  82. package/backend/src/core/node-registries/typeNodes.js +22 -0
  83. package/backend/src/core/node-registries/usersNodes.js +126 -0
  84. package/backend/src/core/nodes/arrays/shuffle.js +14 -0
  85. package/backend/src/core/nodes/bot/get_name.js +8 -0
  86. package/backend/src/core/nodes/bot/stop_bot.js +5 -0
  87. package/backend/src/core/nodes/container/open.js +101 -111
  88. package/backend/src/core/nodes/data/store_read.js +26 -0
  89. package/backend/src/core/nodes/data/store_write.js +23 -0
  90. package/backend/src/core/nodes/event/call_event.js +31 -0
  91. package/backend/src/core/nodes/event/custom_event.js +8 -0
  92. package/backend/src/core/nodes/flow/timer.js +35 -0
  93. package/backend/src/core/nodes/inventory/drop.js +73 -65
  94. package/backend/src/core/nodes/inventory/equip.js +54 -45
  95. package/backend/src/core/nodes/inventory/select_slot.js +48 -46
  96. package/backend/src/core/nodes/navigation/follow.js +54 -51
  97. package/backend/src/core/nodes/navigation/go_to.js +41 -53
  98. package/backend/src/core/nodes/navigation/go_to_entity.js +65 -69
  99. package/backend/src/core/nodes/navigation/go_to_player.js +65 -70
  100. package/backend/src/core/nodes/navigation/stop.js +17 -26
  101. package/backend/src/core/nodes/users/add_to_group.js +24 -0
  102. package/backend/src/core/nodes/users/check_permission.js +26 -0
  103. package/backend/src/core/nodes/users/remove_from_group.js +24 -0
  104. package/backend/src/core/services/BotIPCMessageRouter.js +337 -0
  105. package/backend/src/core/services/BotLifecycleService.js +41 -632
  106. package/backend/src/core/services/CacheManager.js +83 -23
  107. package/backend/src/core/services/CrashRestartManager.js +42 -0
  108. package/backend/src/core/services/DebugSessionManager.js +114 -12
  109. package/backend/src/core/services/EventGraphService.js +69 -0
  110. package/backend/src/core/services/MinecraftBotManager.js +9 -1
  111. package/backend/src/core/services/PluginManagementService.js +84 -0
  112. package/backend/src/core/services/TestModeContext.js +65 -0
  113. package/backend/src/core/services/__tests__/CacheManager.test.js +168 -0
  114. package/backend/src/core/services.js +1 -11
  115. package/backend/src/core/validation/InputValidator.js +167 -0
  116. package/backend/src/core/validation/__tests__/InputValidator.test.js +296 -0
  117. package/backend/src/real-time/botApi/index.js +1 -1
  118. package/backend/src/real-time/socketHandler.js +26 -0
  119. package/backend/src/server.js +10 -5
  120. package/frontend/dist/assets/{browser-ponyfill-DN7pwmHT.js → browser-ponyfill-D8y0Ty7C.js} +1 -1
  121. package/frontend/dist/assets/index-CFJLS0dk.css +32 -0
  122. package/frontend/dist/assets/{index-LSy71uwm.js → index-D91UGNMG.js} +1880 -1881
  123. package/frontend/dist/index.html +2 -2
  124. package/frontend/dist/locales/en/bots.json +4 -1
  125. package/frontend/dist/locales/en/common.json +7 -1
  126. package/frontend/dist/locales/en/login.json +2 -0
  127. package/frontend/dist/locales/en/management.json +79 -1
  128. package/frontend/dist/locales/en/nodes.json +59 -4
  129. package/frontend/dist/locales/en/plugin-detail.json +24 -4
  130. package/frontend/dist/locales/en/plugins.json +226 -7
  131. package/frontend/dist/locales/en/setup.json +2 -0
  132. package/frontend/dist/locales/en/sidebar.json +171 -3
  133. package/frontend/dist/locales/en/visual-editor.json +230 -31
  134. package/frontend/dist/locales/ru/bots.json +4 -1
  135. package/frontend/dist/locales/ru/login.json +2 -0
  136. package/frontend/dist/locales/ru/management.json +79 -1
  137. package/frontend/dist/locales/ru/minecraft-viewer.json +3 -0
  138. package/frontend/dist/locales/ru/nodes.json +105 -51
  139. package/frontend/dist/locales/ru/plugins.json +103 -4
  140. package/frontend/dist/locales/ru/setup.json +2 -0
  141. package/frontend/dist/locales/ru/sidebar.json +171 -3
  142. package/frontend/dist/locales/ru/visual-editor.json +232 -33
  143. package/frontend/package.json +2 -0
  144. package/nul +12 -0
  145. package/package.json +3 -3
  146. package/backend/package-lock.json +0 -6801
  147. package/backend/src/core/node-registries/actions.js +0 -202
  148. package/backend/src/core/node-registries/arrays.js +0 -155
  149. package/backend/src/core/node-registries/bot.js +0 -23
  150. package/backend/src/core/node-registries/container.js +0 -162
  151. package/backend/src/core/node-registries/data.js +0 -290
  152. package/backend/src/core/node-registries/debug.js +0 -26
  153. package/backend/src/core/node-registries/events.js +0 -201
  154. package/backend/src/core/node-registries/flow.js +0 -139
  155. package/backend/src/core/node-registries/furnace.js +0 -143
  156. package/backend/src/core/node-registries/logic.js +0 -62
  157. package/backend/src/core/node-registries/math.js +0 -42
  158. package/backend/src/core/node-registries/navigation.js +0 -111
  159. package/backend/src/core/node-registries/objects.js +0 -98
  160. package/backend/src/core/node-registries/strings.js +0 -187
  161. package/backend/src/core/node-registries/time.js +0 -113
  162. package/backend/src/core/node-registries/type.js +0 -25
  163. package/backend/src/core/node-registries/users.js +0 -79
  164. package/frontend/dist/assets/index-SfhKxI4-.css +0 -32
@@ -0,0 +1,61 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+
6
+ const AUDIT_LOG_DIR = path.join(os.homedir(), '.blockmine', 'logs');
7
+ const AUDIT_LOG_FILE = path.join(AUDIT_LOG_DIR, 'audit.log');
8
+
9
+ class AuditLogger {
10
+ constructor({ logFile = AUDIT_LOG_FILE } = {}) {
11
+ this.logFile = logFile;
12
+ this._ensureDir();
13
+ }
14
+
15
+ _ensureDir() {
16
+ const dir = path.dirname(this.logFile);
17
+ if (!fs.existsSync(dir)) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+ }
21
+
22
+ log(event) {
23
+ const entry = {
24
+ timestamp: new Date().toISOString(),
25
+ ...event,
26
+ };
27
+
28
+ const line = JSON.stringify(entry);
29
+ const hash = crypto.createHash('sha256').update(line).digest('hex');
30
+ const record = JSON.stringify({ ...entry, _hash: hash }) + '\n';
31
+
32
+ try {
33
+ fs.appendFileSync(this.logFile, record, 'utf8');
34
+ } catch {}
35
+ }
36
+
37
+ authFailure({ userId, username, ip, reason }) {
38
+ this.log({ type: 'auth.failure', userId, username, ip, reason });
39
+ }
40
+
41
+ authSuccess({ userId, username, ip }) {
42
+ this.log({ type: 'auth.success', userId, username, ip });
43
+ }
44
+
45
+ authorizationFailure({ userId, username, resource, action, ip }) {
46
+ this.log({ type: 'authorization.failure', userId, username, resource, action, ip });
47
+ }
48
+
49
+ sensitiveAction({ userId, username, action, resource, ip }) {
50
+ this.log({ type: 'sensitive.action', userId, username, action, resource, ip });
51
+ }
52
+ }
53
+
54
+ let instance = null;
55
+
56
+ function getAuditLogger() {
57
+ if (!instance) instance = new AuditLogger();
58
+ return instance;
59
+ }
60
+
61
+ module.exports = { AuditLogger, getAuditLogger };
@@ -0,0 +1,80 @@
1
+ const ErrorHandler = require('../errors/ErrorHandler');
2
+
3
+ const LOG_LEVELS = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3,
8
+ fatal: 4,
9
+ };
10
+
11
+ class StructuredLogger {
12
+ constructor({ minLevel = 'info', context = {} } = {}) {
13
+ this.minLevel = minLevel;
14
+ this.context = context;
15
+ }
16
+
17
+ _log(level, dataOrMessage, message) {
18
+ if (LOG_LEVELS[level] < LOG_LEVELS[this.minLevel]) return;
19
+
20
+ let data = {};
21
+ let msg;
22
+
23
+ if (message !== undefined) {
24
+ data = typeof dataOrMessage === 'object' && dataOrMessage !== null ? dataOrMessage : {};
25
+ msg = message;
26
+ } else {
27
+ msg = dataOrMessage;
28
+ }
29
+
30
+ const sanitized = ErrorHandler.sanitize({ ...this.context, ...data });
31
+
32
+ const entry = {
33
+ timestamp: new Date().toISOString(),
34
+ level,
35
+ message: msg,
36
+ ...sanitized,
37
+ };
38
+
39
+ const output = JSON.stringify(entry);
40
+
41
+ if (level === 'fatal' || level === 'error') {
42
+ console.error(output);
43
+ } else if (level === 'warn') {
44
+ console.warn(output);
45
+ } else if (level === 'debug') {
46
+ console.debug(output);
47
+ } else {
48
+ console.info(output);
49
+ }
50
+ }
51
+
52
+ debug(dataOrMessage, message) {
53
+ this._log('debug', dataOrMessage, message);
54
+ }
55
+
56
+ info(dataOrMessage, message) {
57
+ this._log('info', dataOrMessage, message);
58
+ }
59
+
60
+ warn(dataOrMessage, message) {
61
+ this._log('warn', dataOrMessage, message);
62
+ }
63
+
64
+ error(dataOrMessage, message) {
65
+ this._log('error', dataOrMessage, message);
66
+ }
67
+
68
+ fatal(dataOrMessage, message) {
69
+ this._log('fatal', dataOrMessage, message);
70
+ }
71
+
72
+ child(context) {
73
+ return new StructuredLogger({
74
+ minLevel: this.minLevel,
75
+ context: ErrorHandler.sanitize({ ...this.context, ...context }),
76
+ });
77
+ }
78
+ }
79
+
80
+ module.exports = StructuredLogger;
@@ -0,0 +1,213 @@
1
+ const StructuredLogger = require('../StructuredLogger');
2
+
3
+ describe('StructuredLogger', () => {
4
+ let logger;
5
+
6
+ beforeEach(() => {
7
+ logger = new StructuredLogger({ minLevel: 'debug' });
8
+ });
9
+
10
+ describe('log output structure', () => {
11
+ it('includes timestamp, level, and message in every log entry', () => {
12
+ const output = [];
13
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
14
+
15
+ logger.info('test message');
16
+
17
+ expect(output).toHaveLength(1);
18
+ expect(output[0]).toMatchObject({
19
+ level: 'info',
20
+ message: 'test message',
21
+ });
22
+ expect(output[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
23
+ });
24
+
25
+ it('accepts (data, message) signature and merges data into entry', () => {
26
+ const output = [];
27
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
28
+
29
+ logger.info({ botId: 'bot-1', userId: 'user-1', requestId: 'req-1' }, 'with context');
30
+
31
+ expect(output[0]).toMatchObject({
32
+ level: 'info',
33
+ message: 'with context',
34
+ botId: 'bot-1',
35
+ userId: 'user-1',
36
+ requestId: 'req-1',
37
+ });
38
+ });
39
+
40
+ it('accepts (message) signature without data', () => {
41
+ const output = [];
42
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
43
+
44
+ logger.info('simple message');
45
+
46
+ expect(output[0].message).toBe('simple message');
47
+ });
48
+ });
49
+
50
+ describe('log levels', () => {
51
+ it('calls console.debug for debug level', () => {
52
+ const spy = jest.spyOn(console, 'debug').mockImplementation(() => {});
53
+ logger.debug('debug msg');
54
+ expect(spy).toHaveBeenCalled();
55
+ });
56
+
57
+ it('calls console.info for info level', () => {
58
+ const spy = jest.spyOn(console, 'info').mockImplementation(() => {});
59
+ logger.info('info msg');
60
+ expect(spy).toHaveBeenCalled();
61
+ });
62
+
63
+ it('calls console.warn for warn level', () => {
64
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
65
+ logger.warn('warn msg');
66
+ expect(spy).toHaveBeenCalled();
67
+ });
68
+
69
+ it('calls console.error for error level', () => {
70
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
71
+ logger.error('error msg');
72
+ expect(spy).toHaveBeenCalled();
73
+ });
74
+
75
+ it('calls console.error for fatal level', () => {
76
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
77
+ logger.fatal('fatal msg');
78
+ expect(spy).toHaveBeenCalled();
79
+ });
80
+ });
81
+
82
+ describe('minimum log level filtering', () => {
83
+ it('suppresses messages below the configured minimum level', () => {
84
+ const warnLogger = new StructuredLogger({ minLevel: 'warn' });
85
+ const spy = jest.spyOn(console, 'info').mockImplementation(() => {});
86
+ const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
87
+
88
+ warnLogger.info('should be suppressed');
89
+ warnLogger.debug('should be suppressed');
90
+
91
+ expect(spy).not.toHaveBeenCalled();
92
+ expect(debugSpy).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('outputs messages at or above the minimum level', () => {
96
+ const warnLogger = new StructuredLogger({ minLevel: 'warn' });
97
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
98
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
99
+
100
+ warnLogger.warn('warn passes');
101
+ warnLogger.error('error passes');
102
+ warnLogger.fatal('fatal passes');
103
+
104
+ expect(warnSpy).toHaveBeenCalledTimes(1);
105
+ expect(errorSpy).toHaveBeenCalledTimes(2);
106
+ });
107
+
108
+ it('defaults to info level', () => {
109
+ const defaultLogger = new StructuredLogger();
110
+ const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
111
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
112
+
113
+ defaultLogger.debug('suppressed');
114
+ defaultLogger.info('passes');
115
+
116
+ expect(debugSpy).not.toHaveBeenCalled();
117
+ expect(infoSpy).toHaveBeenCalledTimes(1);
118
+ });
119
+ });
120
+
121
+ describe('sensitive data redaction', () => {
122
+ it('redacts password fields', () => {
123
+ const output = [];
124
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
125
+
126
+ logger.info({ password: 'secret123', userId: 'u1' }, 'login attempt');
127
+
128
+ expect(output[0].password).toBe('[REDACTED]');
129
+ expect(output[0].userId).toBe('u1');
130
+ });
131
+
132
+ it('redacts token fields', () => {
133
+ const output = [];
134
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
135
+
136
+ logger.info({ token: 'abc123', botId: 'b1' }, 'token check');
137
+
138
+ expect(output[0].token).toBe('[REDACTED]');
139
+ expect(output[0].botId).toBe('b1');
140
+ });
141
+
142
+ it('redacts apiKey fields', () => {
143
+ const output = [];
144
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
145
+
146
+ logger.info({ apiKey: 'key-xyz' }, 'api call');
147
+
148
+ expect(output[0].apiKey).toBe('[REDACTED]');
149
+ });
150
+ });
151
+
152
+ describe('child logger', () => {
153
+ it('creates a child logger with pre-bound context', () => {
154
+ const output = [];
155
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
156
+
157
+ const child = logger.child({ botId: 'bot-42', requestId: 'req-99' });
158
+ child.info('child message');
159
+
160
+ expect(output[0]).toMatchObject({
161
+ botId: 'bot-42',
162
+ requestId: 'req-99',
163
+ message: 'child message',
164
+ });
165
+ });
166
+
167
+ it('child context is merged with per-call data', () => {
168
+ const output = [];
169
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
170
+
171
+ const child = logger.child({ botId: 'bot-1' });
172
+ child.info({ userId: 'user-5' }, 'merged');
173
+
174
+ expect(output[0]).toMatchObject({
175
+ botId: 'bot-1',
176
+ userId: 'user-5',
177
+ message: 'merged',
178
+ });
179
+ });
180
+
181
+ it('child inherits parent minLevel', () => {
182
+ const parent = new StructuredLogger({ minLevel: 'error' });
183
+ const child = parent.child({ botId: 'b1' });
184
+ const spy = jest.spyOn(console, 'info').mockImplementation(() => {});
185
+
186
+ child.info('should be suppressed');
187
+
188
+ expect(spy).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it('redacts sensitive fields in child context', () => {
192
+ const child = logger.child({ token: 'secret-token', botId: 'b1' });
193
+ const output = [];
194
+ jest.spyOn(console, 'info').mockImplementation((msg) => output.push(JSON.parse(msg)));
195
+
196
+ child.info('check redaction');
197
+
198
+ expect(output[0].token).toBe('[REDACTED]');
199
+ expect(output[0].botId).toBe('b1');
200
+ });
201
+ });
202
+
203
+ describe('output format', () => {
204
+ it('outputs valid JSON', () => {
205
+ const rawOutput = [];
206
+ jest.spyOn(console, 'info').mockImplementation((msg) => rawOutput.push(msg));
207
+
208
+ logger.info({ botId: 'b1' }, 'json check');
209
+
210
+ expect(() => JSON.parse(rawOutput[0])).not.toThrow();
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,7 @@
1
+ const StructuredLogger = require('./StructuredLogger');
2
+
3
+ const defaultLogger = new StructuredLogger({
4
+ minLevel: process.env.LOG_LEVEL || 'info',
5
+ });
6
+
7
+ module.exports = { StructuredLogger, defaultLogger };
@@ -0,0 +1,104 @@
1
+ class MetricsCollector {
2
+ constructor() {
3
+ this._counters = new Map();
4
+ this._histograms = new Map();
5
+ this._gauges = new Map();
6
+ }
7
+
8
+ incrementCounter(name, labels = {}, value = 1) {
9
+ const key = this._key(name, labels);
10
+ this._counters.set(key, { name, labels, value: (this._counters.get(key)?.value || 0) + value });
11
+ }
12
+
13
+ recordDuration(name, durationMs, labels = {}) {
14
+ const key = this._key(name, labels);
15
+ const existing = this._histograms.get(key) || { name, labels, count: 0, sum: 0, min: Infinity, max: -Infinity, buckets: [] };
16
+ existing.count++;
17
+ existing.sum += durationMs;
18
+ existing.min = Math.min(existing.min, durationMs);
19
+ existing.max = Math.max(existing.max, durationMs);
20
+ existing.buckets.push(durationMs);
21
+ this._histograms.set(key, existing);
22
+ }
23
+
24
+ setGauge(name, value, labels = {}) {
25
+ const key = this._key(name, labels);
26
+ this._gauges.set(key, { name, labels, value });
27
+ }
28
+
29
+ getCounter(name, labels = {}) {
30
+ return this._counters.get(this._key(name, labels))?.value || 0;
31
+ }
32
+
33
+ getHistogram(name, labels = {}) {
34
+ const h = this._histograms.get(this._key(name, labels));
35
+ if (!h) return null;
36
+ const avg = h.count > 0 ? h.sum / h.count : 0;
37
+ const p50 = this._percentile(h.buckets, 50);
38
+ const p95 = this._percentile(h.buckets, 95);
39
+ const p99 = this._percentile(h.buckets, 99);
40
+ return { count: h.count, sum: h.sum, min: h.min, max: h.max, avg, p50, p95, p99 };
41
+ }
42
+
43
+ getGauge(name, labels = {}) {
44
+ return this._gauges.get(this._key(name, labels))?.value ?? null;
45
+ }
46
+
47
+ reset() {
48
+ this._counters.clear();
49
+ this._histograms.clear();
50
+ this._gauges.clear();
51
+ }
52
+
53
+ toPrometheus() {
54
+ const lines = [];
55
+
56
+ for (const { name, labels, value } of this._counters.values()) {
57
+ lines.push(`# TYPE ${name} counter`);
58
+ lines.push(`${name}${this._labelsStr(labels)} ${value}`);
59
+ }
60
+
61
+ for (const { name, labels, count, sum, min, max } of this._histograms.values()) {
62
+ lines.push(`# TYPE ${name} histogram`);
63
+ lines.push(`${name}_count${this._labelsStr(labels)} ${count}`);
64
+ lines.push(`${name}_sum${this._labelsStr(labels)} ${sum}`);
65
+ lines.push(`${name}_min${this._labelsStr(labels)} ${min}`);
66
+ lines.push(`${name}_max${this._labelsStr(labels)} ${max}`);
67
+ }
68
+
69
+ for (const { name, labels, value } of this._gauges.values()) {
70
+ lines.push(`# TYPE ${name} gauge`);
71
+ lines.push(`${name}${this._labelsStr(labels)} ${value}`);
72
+ }
73
+
74
+ return lines.join('\n');
75
+ }
76
+
77
+ _key(name, labels) {
78
+ const labelStr = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}="${v}"`).join(',');
79
+ return labelStr ? `${name}{${labelStr}}` : name;
80
+ }
81
+
82
+ _labelsStr(labels) {
83
+ const entries = Object.entries(labels);
84
+ if (entries.length === 0) return '';
85
+ const parts = entries.map(([k, v]) => `${k}="${v}"`).join(',');
86
+ return `{${parts}}`;
87
+ }
88
+
89
+ _percentile(values, p) {
90
+ if (values.length === 0) return 0;
91
+ const sorted = [...values].sort((a, b) => a - b);
92
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
93
+ return sorted[Math.max(0, idx)];
94
+ }
95
+ }
96
+
97
+ let instance = null;
98
+
99
+ function getMetricsCollector() {
100
+ if (!instance) instance = new MetricsCollector();
101
+ return instance;
102
+ }
103
+
104
+ module.exports = { MetricsCollector, getMetricsCollector };
@@ -0,0 +1,131 @@
1
+ const { MetricsCollector } = require('../MetricsCollector');
2
+
3
+ describe('MetricsCollector', () => {
4
+ let metrics;
5
+
6
+ beforeEach(() => {
7
+ metrics = new MetricsCollector();
8
+ });
9
+
10
+ describe('counters', () => {
11
+ it('starts at 0', () => {
12
+ expect(metrics.getCounter('requests')).toBe(0);
13
+ });
14
+
15
+ it('increments counter', () => {
16
+ metrics.incrementCounter('requests');
17
+ metrics.incrementCounter('requests');
18
+ expect(metrics.getCounter('requests')).toBe(2);
19
+ });
20
+
21
+ it('increments by custom value', () => {
22
+ metrics.incrementCounter('bytes', {}, 100);
23
+ metrics.incrementCounter('bytes', {}, 50);
24
+ expect(metrics.getCounter('bytes')).toBe(150);
25
+ });
26
+
27
+ it('isolates counters by labels', () => {
28
+ metrics.incrementCounter('requests', { method: 'GET' });
29
+ metrics.incrementCounter('requests', { method: 'POST' });
30
+ expect(metrics.getCounter('requests', { method: 'GET' })).toBe(1);
31
+ expect(metrics.getCounter('requests', { method: 'POST' })).toBe(1);
32
+ });
33
+ });
34
+
35
+ describe('histograms', () => {
36
+ it('returns null for unknown histogram', () => {
37
+ expect(metrics.getHistogram('duration')).toBeNull();
38
+ });
39
+
40
+ it('records duration and computes stats', () => {
41
+ metrics.recordDuration('duration', 10);
42
+ metrics.recordDuration('duration', 20);
43
+ metrics.recordDuration('duration', 30);
44
+ const h = metrics.getHistogram('duration');
45
+ expect(h.count).toBe(3);
46
+ expect(h.sum).toBe(60);
47
+ expect(h.min).toBe(10);
48
+ expect(h.max).toBe(30);
49
+ expect(h.avg).toBe(20);
50
+ });
51
+
52
+ it('computes percentiles', () => {
53
+ for (let i = 1; i <= 100; i++) metrics.recordDuration('latency', i);
54
+ const h = metrics.getHistogram('latency');
55
+ expect(h.p50).toBe(50);
56
+ expect(h.p95).toBe(95);
57
+ expect(h.p99).toBe(99);
58
+ });
59
+
60
+ it('isolates histograms by labels', () => {
61
+ metrics.recordDuration('duration', 10, { route: '/a' });
62
+ metrics.recordDuration('duration', 20, { route: '/b' });
63
+ expect(metrics.getHistogram('duration', { route: '/a' }).count).toBe(1);
64
+ expect(metrics.getHistogram('duration', { route: '/b' }).count).toBe(1);
65
+ });
66
+ });
67
+
68
+ describe('gauges', () => {
69
+ it('returns null for unknown gauge', () => {
70
+ expect(metrics.getGauge('bots')).toBeNull();
71
+ });
72
+
73
+ it('sets and gets gauge value', () => {
74
+ metrics.setGauge('bots', 5);
75
+ expect(metrics.getGauge('bots')).toBe(5);
76
+ });
77
+
78
+ it('overwrites gauge value', () => {
79
+ metrics.setGauge('bots', 5);
80
+ metrics.setGauge('bots', 3);
81
+ expect(metrics.getGauge('bots')).toBe(3);
82
+ });
83
+
84
+ it('isolates gauges by labels', () => {
85
+ metrics.setGauge('bots', 2, { status: 'running' });
86
+ metrics.setGauge('bots', 1, { status: 'stopped' });
87
+ expect(metrics.getGauge('bots', { status: 'running' })).toBe(2);
88
+ expect(metrics.getGauge('bots', { status: 'stopped' })).toBe(1);
89
+ });
90
+ });
91
+
92
+ describe('reset', () => {
93
+ it('clears all metrics', () => {
94
+ metrics.incrementCounter('requests');
95
+ metrics.recordDuration('duration', 10);
96
+ metrics.setGauge('bots', 5);
97
+ metrics.reset();
98
+ expect(metrics.getCounter('requests')).toBe(0);
99
+ expect(metrics.getHistogram('duration')).toBeNull();
100
+ expect(metrics.getGauge('bots')).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe('toPrometheus', () => {
105
+ it('outputs counter in Prometheus format', () => {
106
+ metrics.incrementCounter('http_requests_total', { method: 'GET' }, 3);
107
+ const output = metrics.toPrometheus();
108
+ expect(output).toContain('# TYPE http_requests_total counter');
109
+ expect(output).toContain('http_requests_total{method="GET"} 3');
110
+ });
111
+
112
+ it('outputs histogram in Prometheus format', () => {
113
+ metrics.recordDuration('request_duration_ms', 100);
114
+ const output = metrics.toPrometheus();
115
+ expect(output).toContain('# TYPE request_duration_ms histogram');
116
+ expect(output).toContain('request_duration_ms_count 1');
117
+ expect(output).toContain('request_duration_ms_sum 100');
118
+ });
119
+
120
+ it('outputs gauge in Prometheus format', () => {
121
+ metrics.setGauge('active_bots', 7);
122
+ const output = metrics.toPrometheus();
123
+ expect(output).toContain('# TYPE active_bots gauge');
124
+ expect(output).toContain('active_bots 7');
125
+ });
126
+
127
+ it('returns empty string when no metrics', () => {
128
+ expect(metrics.toPrometheus()).toBe('');
129
+ });
130
+ });
131
+ });