@vida-global/core 1.4.5 → 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 (55) hide show
  1. package/AGENTS.md +13 -0
  2. package/README.md +1 -0
  3. package/config/newrelic-config.js +118 -0
  4. package/index.js +9 -5
  5. package/lib/activeRecord/baseRecord.js +49 -3
  6. package/lib/apm/agent.js +22 -0
  7. package/lib/apm/index.js +54 -0
  8. package/lib/apm/utils.js +43 -0
  9. package/lib/http/client.js +16 -2
  10. package/lib/jobQueue/README.md +63 -0
  11. package/lib/jobQueue/abstractJob.js +103 -0
  12. package/lib/jobQueue/abstractJobComponent.js +37 -0
  13. package/lib/jobQueue/index.js +10 -0
  14. package/lib/jobQueue/jobImporter.js +19 -0
  15. package/lib/jobQueue/queue.js +161 -0
  16. package/lib/jobQueue/worker.js +273 -0
  17. package/lib/logger/README.md +33 -2
  18. package/lib/logger/index.js +117 -20
  19. package/lib/logger/serverMiddleware.js +41 -0
  20. package/lib/redis/redisClient.js +32 -24
  21. package/lib/server/controllerImporter.js +9 -47
  22. package/lib/server/server.js +34 -71
  23. package/lib/server/serverController.js +215 -23
  24. package/lib/utils/abstractAutoImporter.js +64 -0
  25. package/package.json +8 -6
  26. package/test/activeRecord/baseRecord.test.js +129 -0
  27. package/test/activeRecord/db/connection.test.js +4 -1
  28. package/test/activeRecord/db/connectionConfiguration.test.js +9 -3
  29. package/test/activeRecord/db/schema.test.js +217 -0
  30. package/test/activeRecord/helpers/baseRecordMocks.js +6 -4
  31. package/test/activeRecord/helpers/connection.js +0 -3
  32. package/test/activeRecord/helpers/connectionConfiguration.js +0 -8
  33. package/test/activeRecord/utils.test.js +72 -0
  34. package/test/apm/agent.test.js +56 -0
  35. package/test/apm/utils.test.js +121 -0
  36. package/test/helpers/env.js +33 -0
  37. package/test/http/client.test.js +3 -3
  38. package/test/jobQueue/abstractJob.test.js +307 -0
  39. package/test/jobQueue/abstractJobComponent.test.js +110 -0
  40. package/test/jobQueue/helpers/abstractJob.js +26 -0
  41. package/test/jobQueue/helpers/apmMock.js +20 -0
  42. package/test/jobQueue/helpers/bullmqMock.js +67 -0
  43. package/test/jobQueue/helpers/env.js +29 -0
  44. package/test/jobQueue/helpers/fixtureJobs/notAJob.js +8 -0
  45. package/test/jobQueue/helpers/fixtureJobs/testJobA.js +14 -0
  46. package/test/jobQueue/helpers/fixtureJobs/testJobB.js +14 -0
  47. package/test/jobQueue/helpers/loggerMock.js +43 -0
  48. package/test/jobQueue/helpers/worker.js +38 -0
  49. package/test/jobQueue/queue.test.js +320 -0
  50. package/test/jobQueue/worker.test.js +526 -0
  51. package/test/logger/index.test.js +61 -0
  52. package/test/logger/serverMiddleware.test.js +72 -0
  53. package/test/server/apiDocsGenerator.test.js +38 -0
  54. package/test/server/server.test.js +186 -4
  55. package/test/server/serverController.test.js +407 -5
@@ -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
+ }
@@ -1,2 +1,33 @@
1
- ## Logger ##
2
- A standard logger to be used across all Vida applications. Currently, a simple implementation of `pino`, supports the full `pino` API.
1
+ # Logger
2
+ A simple logger that support multiple scopes and log levels. Set the `LOG_LEVEL` environment variable to determine what log levels are output.
3
+
4
+ ## Scopes
5
+ ```
6
+ logger.addScope('foo');
7
+ logger.addScope('bar');
8
+
9
+ logger.foo.debug('my log');
10
+ // [2026-04-30 16:16:12.797 -0700][DEBUG][FOO] my log
11
+
12
+ logger.bar.verbose('my log');
13
+ // [2026-04-30 16:16:12.797 -0700][VERBOSE][BAR] my log
14
+
15
+ logger.bar.info('my log');
16
+ // [2026-04-30 16:16:12.797 -0700][INFO][BAR] my log
17
+
18
+ logger.bar.warn('my log');
19
+ // [2026-04-30 16:16:12.797 -0700][WARN][BAR] my log
20
+
21
+ logger.bar.error('my log');
22
+ // [2026-04-30 16:16:12.797 -0700][ERROR][BAR] my log
23
+ ```
24
+
25
+ ## IDs
26
+ ```
27
+ logger.addScope('http');
28
+ const requestId = 123;
29
+ const requestLogger = logger.http.child(requestId);
30
+
31
+ requestLogger.debug('my log');
32
+ // [2026-04-30 16:16:12.797 -0700][DEBUG][HTTP][123] my log
33
+ ``
@@ -1,25 +1,122 @@
1
- const pino = require('pino');
2
-
3
- const config = {
4
- level: process.env.LOG_LEVEL || 'info',
5
- customLevels: {
6
- test: 1000
7
- }
8
- };
9
-
10
- if (!process.env.ENV_VERCEL) {
11
- config.transport = {
12
- target: 'pino-pretty',
13
- options: {
14
- colorize: true,
15
- ignore: 'pid,hostname',
16
- translateTime: 'SYS:standard',
17
- messageFormat: '{msg}',
1
+ const winston = require('winston');
2
+ const { colorize, combine, printf, timestamp } = winston.format;
3
+
4
+
5
+ const loggers = {};
6
+
7
+
8
+ class Logger {
9
+ #id;
10
+ #logger;
11
+ #scopes;
12
+
13
+
14
+ constructor(scopes, id) {
15
+ this.#id = id;
16
+ scopes = Array.isArray(scopes) ? scopes : [scopes];
17
+ this.#scopes = scopes.filter(Boolean).map(scope => scope.toLowerCase());
18
+ this.#logger = winston.createLogger(this.loggerOptions);
19
+ }
20
+
21
+
22
+ get scopes() { return this.#scopes; }
23
+ get id() { return this.#id; }
24
+
25
+
26
+ get loggerOptions() {
27
+ const format = combine(...this.formatters);
28
+ const level = this.level;
29
+ const transports = this.transports;
30
+
31
+ return { format, level, transports };
32
+ }
33
+
34
+
35
+ get formatters() {
36
+ const colors = {
37
+ debug: 'magenta',
38
+ verbose: 'green',
39
+ info: 'cyan',
40
+ warn: 'yellow',
41
+ error: 'red',
18
42
  }
19
- };
43
+ const messageFormatter = winston.format(this.messageFormatter.bind(this));
44
+ return [
45
+ timestamp({format: 'YYYY-MM-DD HH:mm:ss.SSS ZZ'}),
46
+ messageFormatter(),
47
+ colorize({all: true, colors}),
48
+ printf(({ message }) => message)
49
+ ];
50
+ }
51
+
52
+
53
+ messageFormatter(info) {
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}`;
60
+
61
+ return info;
62
+ }
63
+
64
+
65
+ get level() {
66
+ return process.env.LOG_LEVEL || 'info';
67
+ }
68
+
69
+
70
+ get transports() {
71
+ const transports = [ new winston.transports.Console() ];
72
+ return transports;
73
+ }
74
+
75
+
76
+ addScope(scope) {
77
+ scope = scope.toLowerCase();
78
+ const existing = this[scope];
79
+ if (existing) {
80
+ if (!existing instanceof Logger) throw `Property already exists at ${scope}`;
81
+ return;
82
+ }
83
+
84
+ const logger = this.#createLogger(this.scopes.concat([scope]), this.id);
85
+ Object.defineProperty(this, scope, { get: () => logger });
86
+ }
87
+
88
+
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];
102
+ }
103
+
104
+
105
+ debug(msg) { this.#logger.debug(msg) }
106
+ error(msg) { this.#logger.error(msg) }
107
+ info(msg) { this.#logger.info(msg) }
108
+ silly(msg) { this.#logger.silly(msg) }
109
+ verbose(msg) { this.#logger.verbose(msg) }
110
+ warn(msg) { this.#logger.warn(msg) }
111
+
112
+ get _winstonLogger() {
113
+ this.warn('Directly accessing the underlying winston logger should be avoided unless necessary');
114
+ return this.#logger
115
+ }
20
116
  }
21
117
 
22
- const logger = pino(config);
118
+
119
+ const logger = new Logger();
23
120
 
24
121
 
25
- module.exports = { logger };
122
+ module.exports = { logger, Logger };
@@ -0,0 +1,41 @@
1
+ const expressWinston = require('express-winston');
2
+
3
+
4
+ const msgFormatter = (req, res) => {
5
+ let msg;
6
+ if (req.controller) {
7
+ msg = `${req.controller.constructor.name}#${req.action}`;
8
+ } else {
9
+ msg = res.statusCode >= 500 ? 'request errored' : 'request completed';
10
+ }
11
+
12
+ const responseTime = res.responseTime || '-';
13
+ const requestDetails = `req: ${req.method} ${req.url.split('?')[0]} for ${req.ip}`;
14
+ const responseDetails = `res: statusCode=${res.statusCode} responseTime=${responseTime}ms`;
15
+
16
+ msg = `${msg}\n ${requestDetails}\n ${responseDetails}`;
17
+ if (res.error) {
18
+ msg = `${msg}\n${res.error.stack}`;
19
+ }
20
+
21
+ return msg;
22
+ };
23
+
24
+
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
+ };
37
+
38
+
39
+ module.exports = {
40
+ middleware
41
+ }