@vida-global/core 1.4.6 → 2.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 (50) hide show
  1. package/README.md +1 -0
  2. package/config/newrelic-config.js +118 -0
  3. package/index.js +9 -5
  4. package/lib/activeRecord/baseRecord.js +49 -3
  5. package/lib/apm/agent.js +22 -0
  6. package/lib/apm/index.js +54 -0
  7. package/lib/apm/utils.js +43 -0
  8. package/lib/http/client.js +16 -2
  9. package/lib/jobQueue/README.md +63 -0
  10. package/lib/jobQueue/abstractJob.js +103 -0
  11. package/lib/jobQueue/abstractJobComponent.js +37 -0
  12. package/lib/jobQueue/index.js +10 -0
  13. package/lib/jobQueue/jobImporter.js +19 -0
  14. package/lib/jobQueue/queue.js +161 -0
  15. package/lib/jobQueue/worker.js +273 -0
  16. package/lib/logger/README.md +1 -1
  17. package/lib/logger/index.js +33 -15
  18. package/lib/logger/serverMiddleware.js +14 -12
  19. package/lib/redis/redisClient.js +32 -24
  20. package/lib/server/controllerImporter.js +9 -47
  21. package/lib/server/server.js +30 -13
  22. package/lib/server/serverController.js +93 -23
  23. package/lib/utils/abstractAutoImporter.js +64 -0
  24. package/package.json +4 -2
  25. package/test/activeRecord/baseRecord.test.js +129 -0
  26. package/test/activeRecord/db/connection.test.js +4 -1
  27. package/test/activeRecord/db/connectionConfiguration.test.js +9 -3
  28. package/test/activeRecord/helpers/baseRecordMocks.js +6 -4
  29. package/test/activeRecord/helpers/connection.js +0 -3
  30. package/test/activeRecord/helpers/connectionConfiguration.js +0 -8
  31. package/test/apm/agent.test.js +56 -0
  32. package/test/apm/utils.test.js +121 -0
  33. package/test/helpers/env.js +33 -0
  34. package/test/http/client.test.js +3 -3
  35. package/test/jobQueue/abstractJob.test.js +307 -0
  36. package/test/jobQueue/abstractJobComponent.test.js +110 -0
  37. package/test/jobQueue/helpers/abstractJob.js +26 -0
  38. package/test/jobQueue/helpers/apmMock.js +20 -0
  39. package/test/jobQueue/helpers/bullmqMock.js +67 -0
  40. package/test/jobQueue/helpers/env.js +29 -0
  41. package/test/jobQueue/helpers/fixtureJobs/notAJob.js +8 -0
  42. package/test/jobQueue/helpers/fixtureJobs/testJobA.js +14 -0
  43. package/test/jobQueue/helpers/fixtureJobs/testJobB.js +14 -0
  44. package/test/jobQueue/helpers/loggerMock.js +43 -0
  45. package/test/jobQueue/helpers/worker.js +38 -0
  46. package/test/jobQueue/queue.test.js +320 -0
  47. package/test/jobQueue/worker.test.js +526 -0
  48. package/test/logger/index.test.js +61 -0
  49. package/test/logger/serverMiddleware.test.js +6 -7
  50. package/test/server/serverController.test.js +180 -10
@@ -0,0 +1,161 @@
1
+ const { AbstractJobComponent } = require('./abstractJobComponent');
2
+ const BullMQ = require('bullmq');
3
+ const { logger } = require('../logger');
4
+ const { randomUUID } = require('crypto');
5
+
6
+
7
+ logger.addScope('queue');
8
+
9
+
10
+ class Queue extends AbstractJobComponent {
11
+ #_queue;
12
+ #queueName;
13
+
14
+
15
+ constructor(queueName) {
16
+ super();
17
+ this.#queueName = queueName;
18
+ }
19
+
20
+
21
+ get queueName() { return this.#queueName }
22
+ get logger() { return logger.queue.child(this.fullQueueName) }
23
+
24
+
25
+ async queueJob(job, args, settings) {
26
+ const id = randomUUID();
27
+ await this.#queue.add(job.name, { id, args }, settings);
28
+ }
29
+
30
+
31
+ /***********************************************************************************************
32
+ * QUEUE SETUP
33
+ ***********************************************************************************************/
34
+ get #queue() {
35
+ if (this.#_queue) return this.#_queue;
36
+
37
+ this.#_queue = new BullMQ.Queue(this.fullQueueName, this.queueOptions);
38
+ this.#_queue.on('error', this.#handleError.bind(this));
39
+
40
+ return this.#_queue
41
+ }
42
+
43
+
44
+ get queueOptions() {
45
+ return { connection: this.redisConnectionDetails };
46
+ }
47
+
48
+
49
+ /***********************************************************************************************
50
+ * QUEUE STATUS
51
+ ***********************************************************************************************/
52
+ async close() {
53
+ await this.#queue.close();
54
+ }
55
+
56
+
57
+ async pause() {
58
+ await this.#queue.pause();
59
+ }
60
+
61
+
62
+ async resume() {
63
+ await this.#queue.resume();
64
+ }
65
+
66
+
67
+ /***********************************************************************************************
68
+ * JOBS
69
+ ***********************************************************************************************/
70
+ async numQueuedJobs() {
71
+ return await this.#queue.count();
72
+ }
73
+
74
+
75
+ async getQueuedJobs() {
76
+ const jobs = await this.#queue.getJobs(["paused", "wait", "prioritized", "delayed"]);
77
+ return this.#_formatJobsData(jobs);
78
+ }
79
+
80
+
81
+ async numActiveJobs() {
82
+ return await this.#queue.getActiveCount();
83
+ }
84
+
85
+
86
+ async getActiveJobs() {
87
+ const jobs = await this.#queue.getActive();
88
+ return this.#_formatJobsData(jobs);
89
+ }
90
+
91
+
92
+ async numFailedJobs() {
93
+ return await this.#queue.getFailedCount();
94
+ }
95
+
96
+
97
+ async getFailedJobs() {
98
+ const jobs = await this.#queue.getFailed();
99
+ return this.#_formatJobsData(jobs);
100
+ }
101
+
102
+
103
+ async clearFailedJobs() {
104
+ await this.#queue.clean(0, null, "failed");
105
+ }
106
+
107
+
108
+ async clearCompletedJobs() {
109
+ await this.#queue.clean(0, null, "completed");
110
+ }
111
+
112
+
113
+ async clearQueuedJobs() {
114
+ return await this.#queue.drain();
115
+ }
116
+
117
+
118
+ async numWorkers() {
119
+ const workerData = await this.#queue.getWorkers();
120
+ return workerData.length;
121
+ }
122
+
123
+
124
+ #_formatJobsData(jobs) {
125
+ return jobs.map((job) => this.#_formatJobData(job));
126
+ }
127
+
128
+
129
+ #_formatJobData(job) {
130
+ return {
131
+ queueName: job.queue.name,
132
+ name: job.name,
133
+ args: job.data,
134
+ id: job.id,
135
+ attemptsMade: job.attemptsMade,
136
+ attemptsStarted: job.attemptsStarted,
137
+ progress: job.progress,
138
+ failedReason: job.failedReason
139
+ }
140
+ }
141
+
142
+
143
+ /***********************************************************************************************
144
+ * ERROR HANDLING
145
+ ***********************************************************************************************/
146
+ #handleError() {
147
+ this.logger.error(...arguments);
148
+ }
149
+ }
150
+
151
+
152
+ const queues = {};
153
+ function getQueue(name) {
154
+ const fullName = Queue.fullQueueName(name);
155
+ return queues[fullName] ||= new Queue(name);
156
+ }
157
+
158
+
159
+ module.exports = {
160
+ getQueue
161
+ }
@@ -0,0 +1,273 @@
1
+ const APM = require('../apm');
2
+ const { AbstractJobComponent } = require('./abstractJobComponent');
3
+ const BullMQ = require('bullmq');
4
+ const { JobImporter } = require('./jobImporter');
5
+ const { logger } = require('../logger');
6
+ const { randomUUID } = require('crypto');
7
+
8
+
9
+ const JOB_COMPLETED_EVENT = 'JobCompleted';
10
+ const JOB_FAILED_EVENT = 'JobFailed';
11
+
12
+
13
+ function durationMetricName(queueName, jobName) {
14
+ return `Custom/Worker/${queueName}/${jobName}/duration`;
15
+ }
16
+
17
+
18
+ logger.addScope('worker');
19
+ let registeredJobs;
20
+
21
+
22
+ class Worker extends AbstractJobComponent {
23
+ #defaultJob;
24
+ #queueName;
25
+ #_worker;
26
+
27
+
28
+ constructor(queueName) {
29
+ super();
30
+ this.#queueName = queueName || process.env.WORKER_QUEUE_NAME;
31
+ }
32
+
33
+
34
+ async listen() {
35
+ await this.#registerJobs();
36
+ this.logger.info(`Worker is listening on queue ${this.fullQueueName}`);
37
+ await this.#worker.run();
38
+ }
39
+
40
+
41
+ #registerJobs() {
42
+ if (registeredJobs) return;
43
+
44
+ const importer = new JobImporter(this.jobDirectories);
45
+ const jobs = importer.jobs;
46
+ registeredJobs = Object.fromEntries(jobs.map(job => [job.name, job]));
47
+ this.#defaultJob = jobs.find(job => job.defaultFor == this.queueName) || null;
48
+
49
+ if (process.env.NODE_ENV != 'test') {
50
+ for (const job of jobs) {
51
+ this.logger.verbose(`JOB: ${job.name}`);
52
+ }
53
+ }
54
+ }
55
+
56
+
57
+ get logger() {
58
+ return logger.worker;
59
+ }
60
+
61
+
62
+ get jobDirectories() {
63
+ return [`${process.cwd()}/jobs`];
64
+ }
65
+
66
+
67
+ /***********************************************************************************************
68
+ * WORKER SETUP
69
+ ***********************************************************************************************/
70
+ get #worker() {
71
+ if (this.#_worker) return this.#_worker;
72
+
73
+ this.#_worker = new BullMQ.Worker(this.fullQueueName,
74
+ this.processJob.bind(this),
75
+ this.workerOptions);
76
+ this.#setupEventHandling();
77
+
78
+ return this.#_worker;
79
+ }
80
+
81
+
82
+ get workerOptions() {
83
+ const options = {
84
+ autorun: false,
85
+ concurrency: this.concurrency,
86
+ connection: this.redisConnectionDetails
87
+ };
88
+
89
+ return options;
90
+ }
91
+
92
+
93
+ get concurrency() {
94
+ return parseInt(process.env.WORKER_CONCURRENCY || 1);
95
+ }
96
+
97
+
98
+ get queueName() {
99
+ return this.#queueName;
100
+ }
101
+
102
+
103
+ /***********************************************************************************************
104
+ * PROCESS JOB
105
+ ***********************************************************************************************/
106
+ async processJob(bullJob) {
107
+ const jobClass = this.#getJobClass(bullJob.name);
108
+ if (!jobClass) throw `Unknown Job: ${bullJob.name}`;
109
+
110
+ const context = this.#buildJobContext(bullJob, jobClass);
111
+ return await APM.startBackgroundTransaction(context.jobName,
112
+ this.fullQueueName,
113
+ () => this.#runJob(context));
114
+ }
115
+
116
+
117
+ #buildJobContext(bullJob, jobClass) {
118
+ const { id, args } = this.#dataForJob(bullJob);
119
+ return {
120
+ bullJob,
121
+ jobClass,
122
+ jobName: jobClass.name,
123
+ id,
124
+ args,
125
+ queueName: this.queueName,
126
+ attempt: bullJob.attemptsMade + 1,
127
+ maxAttempts: bullJob.opts.attempts,
128
+ };
129
+ }
130
+
131
+
132
+ async #runJob(context) {
133
+ this.#addJobAttributes(context);
134
+
135
+ const startedAt = Date.now();
136
+ try {
137
+ await this.#executeJob(context);
138
+ } catch(err) {
139
+ this.#reportJobError(err, context);
140
+ throw err;
141
+ }
142
+
143
+ const duration = Date.now() - startedAt;
144
+ this.#recordJobSuccess(context, duration);
145
+ return duration;
146
+ }
147
+
148
+
149
+ async #executeJob(context) {
150
+ const { bullJob, jobClass, id, args, jobName } = context;
151
+ const job = new jobClass();
152
+ await APM.startSegment(`job:${jobName}`,
153
+ true,
154
+ () => job._run(id, args, bullJob.updateProgress.bind(bullJob)));
155
+ }
156
+
157
+
158
+ #addJobAttributes(context) {
159
+ APM.addCustomAttribute('jobId', context.id);
160
+ APM.addCustomAttribute('jobName', context.jobName);
161
+ APM.addCustomAttribute('queueName', context.queueName);
162
+ APM.addCustomAttribute('attempt', context.attempt);
163
+ APM.addCustomAttribute('maxAttempts', context.maxAttempts);
164
+ }
165
+
166
+
167
+ #reportJobError(err, context) {
168
+ const isFinalRetry = context.attempt >= context.maxAttempts;
169
+
170
+ APM.reportError(err, {
171
+ jobId: context.id,
172
+ jobName: context.jobName,
173
+ queueName: context.queueName,
174
+ attempt: context.attempt,
175
+ maxAttempts: context.maxAttempts,
176
+ isFinalRetry,
177
+ });
178
+
179
+ if (isFinalRetry) this.#recordJobFailedEvent(context, err);
180
+ }
181
+
182
+
183
+ #recordJobFailedEvent(context, err) {
184
+ APM.recordCustomEvent(JOB_FAILED_EVENT, {
185
+ jobName: context.jobName,
186
+ queueName: context.queueName,
187
+ attempts: context.attempt,
188
+ error: err.message || String(err),
189
+ });
190
+ }
191
+
192
+
193
+ #recordJobSuccess(context, duration) {
194
+ APM.recordCustomEvent(JOB_COMPLETED_EVENT, {
195
+ jobName: context.jobName,
196
+ queueName: context.queueName,
197
+ durationMs: duration,
198
+ attempts: context.attempt,
199
+ });
200
+ APM.recordMetric(durationMetricName(context.queueName, context.jobName), duration);
201
+ }
202
+
203
+
204
+ /***********************************************************************************************
205
+ * EVENT HANDLING
206
+ ***********************************************************************************************/
207
+ #setupEventHandling() {
208
+ this.#_worker.on('completed', this.#handleJobCompletion.bind(this));
209
+ this.#_worker.on('progress', this.#handleJobProgress.bind(this));
210
+ this.#_worker.on('failed', this.#handleJobFailure.bind(this));
211
+ this.#_worker.on('error', this.#handleError.bind(this));
212
+ }
213
+
214
+
215
+ #handleJobCompletion({ name, data }, duration) {
216
+ const logger = this.#loggerForJob({ name, data });
217
+ logger.debug(`Completed in ${duration}ms`);
218
+ }
219
+
220
+
221
+ #handleJobProgress(job, data) {
222
+ const logger = this.#loggerForJob(job);
223
+ logger.debug(data);
224
+ }
225
+
226
+
227
+ #handleJobFailure(job, err) {
228
+ const log = `Job Failed (Attempt ${job.attemptsMade}/${job.opts.attempts}): ${job.name} - ${err}`;
229
+
230
+ const lastRetry = job.attemptsMade == job.opts.attempts;
231
+ const logger = this.#loggerForJob(job);
232
+ logger.debug(`ERROR (${job.attemptsMade}/${job.opts.attempts}) ${err}`);
233
+
234
+ if (lastRetry) {
235
+ throw err;
236
+ }
237
+ }
238
+
239
+
240
+ #handleError(err) {
241
+ this.logger.error(err);
242
+ if (process.env.NODE_ENV == 'development') {
243
+ throw err;
244
+ }
245
+ }
246
+
247
+
248
+ #loggerForJob(bullJob) {
249
+ const { id } = this.#dataForJob(bullJob);
250
+ return logger.worker.child(id);
251
+ }
252
+
253
+
254
+ #getJobClass(name) {
255
+ if (registeredJobs[name]) return registeredJobs[name];
256
+ return this.#defaultJob;
257
+ }
258
+
259
+
260
+ #dataForJob({ name, data }) {
261
+ if (name == '__default__') { // legacy for vida.live. Remove when vida.live has been fully migrated
262
+ return { args: [data] };
263
+ } else {
264
+ const { id, args } = data;
265
+ return { id, args };
266
+ }
267
+ }
268
+ }
269
+
270
+
271
+ module.exports = {
272
+ Worker
273
+ }
@@ -26,7 +26,7 @@ logger.bar.error('my log');
26
26
  ```
27
27
  logger.addScope('http');
28
28
  const requestId = 123;
29
- const requestLogger = logger.http.createChild(requestId);
29
+ const requestLogger = logger.http.child(requestId);
30
30
 
31
31
  requestLogger.debug('my log');
32
32
  // [2026-04-30 16:16:12.797 -0700][DEBUG][HTTP][123] my log
@@ -2,21 +2,25 @@ const winston = require('winston');
2
2
  const { colorize, combine, printf, timestamp } = winston.format;
3
3
 
4
4
 
5
+ const loggers = {};
6
+
7
+
5
8
  class Logger {
6
9
  #id;
7
10
  #logger;
8
- #scope;
11
+ #scopes;
9
12
 
10
13
 
11
- constructor(scope, id) {
14
+ constructor(scopes, id) {
12
15
  this.#id = id;
13
- this.#scope = scope || 'API';
16
+ scopes = Array.isArray(scopes) ? scopes : [scopes];
17
+ this.#scopes = scopes.filter(Boolean).map(scope => scope.toLowerCase());
14
18
  this.#logger = winston.createLogger(this.loggerOptions);
15
19
  }
16
20
 
17
21
 
18
- get scope() { return this.#scope; }
19
- get id() { return this.#id; }
22
+ get scopes() { return this.#scopes; }
23
+ get id() { return this.#id; }
20
24
 
21
25
 
22
26
  get loggerOptions() {
@@ -47,12 +51,13 @@ class Logger {
47
51
 
48
52
 
49
53
  messageFormatter(info) {
50
- const level = info.level.toUpperCase()
54
+ const level = info.level.toUpperCase()
55
+ const id = this.id || info.meta?.id;
56
+ const scopes = this.scopes.map(scope => `[${scope.toUpperCase()}]`).join('');
57
+ let prefix = `[${info.timestamp}][${level}]${scopes}`;
58
+ if (id) prefix = `${prefix}[${id}]`;
59
+ info.message = `${prefix} ${info.message}`;
51
60
 
52
- let prefix = `[${info.timestamp}][${level}][${this.scope.toUpperCase()}]`;
53
- if (this.id) prefix = `${prefix}[${this.id}]`;
54
-
55
- info.message = `${prefix} ${info.message}`;
56
61
  return info;
57
62
  }
58
63
 
@@ -69,18 +74,31 @@ class Logger {
69
74
 
70
75
 
71
76
  addScope(scope) {
77
+ scope = scope.toLowerCase();
72
78
  const existing = this[scope];
73
- if (existing && !existing instanceof Logger) {
74
- throw `Property already exists at ${scope}`;
79
+ if (existing) {
80
+ if (!existing instanceof Logger) throw `Property already exists at ${scope}`;
81
+ return;
75
82
  }
76
83
 
77
- const logger = new Logger(scope, this.id);
84
+ const logger = this.#createLogger(this.scopes.concat([scope]), this.id);
78
85
  Object.defineProperty(this, scope, { get: () => logger });
79
86
  }
80
87
 
81
88
 
82
- createChild(id) {
83
- return new Logger(this.scope, id);
89
+ child(id) {
90
+ return this.#createLogger(this.scopes, id);
91
+ }
92
+
93
+
94
+ #createLogger(scopes, id) {
95
+ scopes = Array.isArray(scopes) ? scopes : [scopes];
96
+ scopes = scopes.map(scope => scope.toLowerCase());
97
+ const key = `${scopes.join('|')}:${id}`;
98
+ if (loggers[key]) return loggers[key];
99
+
100
+ loggers[key] = new Logger(scopes, id);
101
+ return loggers[key];
84
102
  }
85
103
 
86
104
 
@@ -1,4 +1,3 @@
1
- const { logger } = require('./index');
2
1
  const expressWinston = require('express-winston');
3
2
 
4
3
 
@@ -10,12 +9,9 @@ const msgFormatter = (req, res) => {
10
9
  msg = res.statusCode >= 500 ? 'request errored' : 'request completed';
11
10
  }
12
11
 
13
- const headerPcs = res._header.split("\n").map(h => h.split(/:(.*)/));
14
- const headers = Object.fromEntries(headerPcs);
15
- const responseTime = headers['X-Response-Time'];
16
-
12
+ const responseTime = res.responseTime || '-';
17
13
  const requestDetails = `req: ${req.method} ${req.url.split('?')[0]} for ${req.ip}`;
18
- const responseDetails = `res: statusCode=${res.statusCode} responseTime=${responseTime}`;
14
+ const responseDetails = `res: statusCode=${res.statusCode} responseTime=${responseTime}ms`;
19
15
 
20
16
  msg = `${msg}\n ${requestDetails}\n ${responseDetails}`;
21
17
  if (res.error) {
@@ -26,12 +22,18 @@ const msgFormatter = (req, res) => {
26
22
  };
27
23
 
28
24
 
29
- logger.addScope('http');
30
- const middleware = expressWinston.logger({
31
- level: 'debug',
32
- msg: msgFormatter,
33
- winstonInstance: logger.http._winstonLogger,
34
- });
25
+ let _middleware;
26
+
27
+ function middleware(logger) {
28
+ if (_middleware) return _middleware;
29
+ _middleware = expressWinston.logger({
30
+ dynamicMeta: (req, res) => ({id: req.id}),
31
+ level: 'debug',
32
+ msg: msgFormatter,
33
+ winstonInstance: logger._winstonLogger,
34
+ });
35
+ return _middleware;
36
+ };
35
37
 
36
38
 
37
39
  module.exports = {
@@ -3,40 +3,48 @@ const { logger } = require('../logger');
3
3
  const redis = require('redis');
4
4
 
5
5
 
6
- let redisSingleton;
6
+ const CERT_LOCATION = process.env.REDIS_CERT_LOCATION || `${process.cwd()}/redis_ca.pem`;
7
+ const clients = {};
8
+ logger.addScope('redis');
7
9
 
8
10
 
9
- async function initRedisClientSingleton() {
10
- if (redisSingleton) return;
11
+ function redisClientFactory({ host, port, password, subscriber=false } = {}) {
12
+ host = host || process.env.REDIS_HOST;
13
+ port = parseInt(port || process.env.REDIS_PORT);
14
+ password = password || process.env.REDIS_PASSWORD
15
+ const key = `${host}:${port}:${Boolean(subscriber)}`;
11
16
 
12
- const certLocation = process.env.REDIS_CERT_LOCATION || `${process.cwd()}/redis_ca.pem`;
13
- const cert = fs.readFileSync(certLocation)
17
+ if (clients[key]) return clients[key];
14
18
 
15
- redisSingleton = redis.createClient({
16
- socket: {
17
- host: process.env.REDIS_HOST,
18
- port: parseInt(process.env.REDIS_PORT),
19
- tls: true,
20
- ca: [cert]
21
- },
22
- password: process.env.REDIS_PASSWORD
23
- });
19
+ const client = createClient(host, port, password);
20
+ clients[key] = client;
21
+
22
+ configureClient(client)
23
+ return client;
24
+ }
24
25
 
25
- redisSingleton.on('ready', (err) => {
26
- logger.info('Redis Client Connected');
26
+
27
+ function createClient(host, port, password) {
28
+ const ca = fs.readFileSync(CERT_LOCATION)
29
+ const socket = { host, port, ca, tls: true, };
30
+ return redis.createClient({ socket, password });
31
+ }
32
+
33
+
34
+ function configureClient(client) {
35
+ client.on('ready', (err) => {
36
+ logger.redis.info('Connected');
27
37
  });
28
- redisSingleton .on('error', (err) => {
38
+ client.on('error', (err) => {
29
39
  const errno = err.errno ? ` (${err.errno})` : '';
30
- logger.error(`Redis: ${err.message}${errno}`);
40
+ logger.redis.error(`${err.message}${errno}`);
31
41
  });
32
42
 
33
- await redisSingleton.connect();
34
- }
35
-
36
43
 
37
- async function redisClientFactory() {
38
- await initRedisClientSingleton();
39
- return redisSingleton;
44
+ const connect = client.connect.bind(client);
45
+ client.connect = async function() {
46
+ if (!this.isReady) await connect();
47
+ }
40
48
  }
41
49
 
42
50