@opentermsarchive/engine 15.2.0 → 15.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "15.2.0",
3
+ "version": "15.3.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -1,34 +1,13 @@
1
- import os from 'os';
2
-
3
1
  import config from 'config';
4
2
  import dotenv from 'dotenv';
5
3
  import winston from 'winston';
6
4
 
7
- import MailTransportWithRetry from '../logger/mail-transport-with-retry.js';
5
+ import { addErrorMail } from '../logger/error-mail.js';
8
6
 
9
7
  dotenv.config({ quiet: true });
10
8
 
11
9
  const { combine, timestamp, printf, colorize } = winston.format;
12
10
 
13
- const transports = [new winston.transports.Console()];
14
-
15
- if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
16
- transports.push(new MailTransportWithRetry({
17
- to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
18
- from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
19
- host: config.get('@opentermsarchive/engine.logger.smtp.host'),
20
- port: config.get('@opentermsarchive/engine.logger.smtp.port'),
21
- username: config.get('@opentermsarchive/engine.logger.smtp.username'),
22
- password: process.env.OTA_ENGINE_SMTP_PASSWORD,
23
- tls: true,
24
- timeout: 60 * 1000,
25
- formatter: args => args[Object.getOwnPropertySymbols(args)[1]], // Returns the full error message, the same visible in the console. It is referenced in the argument object with a Symbol of which we do not have the reference but we know it is the second one.
26
- exitOnError: true,
27
- level: 'error',
28
- subject: `[OTA API] Error Report — ${os.hostname()}`,
29
- }));
30
- }
31
-
32
11
  const logger = winston.createLogger({
33
12
  format: combine(
34
13
  colorize(),
@@ -39,8 +18,10 @@ const logger = winston.createLogger({
39
18
  return `${timestampPrefix}${level.padEnd(15)} ${message}`;
40
19
  }),
41
20
  ),
42
- transports,
43
- rejectionHandlers: transports,
21
+ transports: [new winston.transports.Console({ handleRejections: true })],
22
+ exitOnError: false,
44
23
  });
45
24
 
25
+ await addErrorMail(logger, { component: 'Collection API', subject: 'API error' });
26
+
46
27
  export default logger;
@@ -0,0 +1,149 @@
1
+ import os from 'os';
2
+
3
+ import config from 'config';
4
+ import winston from 'winston';
5
+
6
+ import { getCollection } from '../archivist/collection/index.js';
7
+
8
+ import MailTransportWithRetry from './mail-transport-with-retry.js';
9
+
10
+ const SMTP_TIMEOUT = 60 * 1000;
11
+
12
+ export const SENDING_BUDGET = SMTP_TIMEOUT; // The duration of one attempt: retries would only delay the restart when the relay is down
13
+
14
+ const escapeHtml = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15
+
16
+ const CODE_STYLE = "font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 6px 10px; display: inline-block; color: #212529; cursor: text; user-select: all; -webkit-user-select: all; -moz-user-select: all; -ms-user-select: all;";
17
+
18
+ const section = (title, color, content) => `
19
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 15px; margin-bottom: 0;">
20
+ <h2 style="color: ${color}; margin: 0 0 0 0; font-size: 20px; border-bottom: 2px solid ${color}; padding-bottom: 8px;">${title}</h2>${content}
21
+ </div>`;
22
+
23
+ const command = (label, code) => `
24
+ <li style="margin: 0; padding: 0">
25
+ <strong style="display: block; margin-bottom: 0;">${label}</strong>
26
+ <code style="${CODE_STYLE}">${escapeHtml(code)}</code>
27
+ </li>`;
28
+
29
+ function formatBody({ collection, component, environmentPrefix }, { message, level }) {
30
+ const isError = level.includes('error');
31
+ const titleColor = isError ? '#dc3545' : '#ffc107';
32
+ const titleText = isError ? 'Error details' : 'Warning details';
33
+ const cpuCount = os.cpus().length;
34
+
35
+ const commands = [
36
+ ...(collection.host && collection.hostConfig?.ansible_user ? [[ 'Connect to the server:', `ssh ${collection.hostConfig.ansible_user}@${collection.host}` ]] : []),
37
+ [ 'List processes on the server:', 'pm2 list' ],
38
+ [ 'View the logs on the server:', 'pm2 logs <process-name>' ],
39
+ [ 'View additional logging options:', 'pm2 logs <process-name> --help' ],
40
+ ];
41
+
42
+ return `
43
+ <!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
48
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
49
+ <title>OTA Error Report</title>
50
+ </head>
51
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333333; max-width: 800px; margin: 0 auto; padding: 0px 20px 20px 20px;">
52
+ <h1 style="color: #212529; font-size: 24px; margin: 10px 0; text-align: center; padding-bottom: 10px;">${escapeHtml(`${environmentPrefix}Open Terms Archive ${component} error report — ${collection.name} Collection`)}</h1>
53
+ ${section(titleText, titleColor, `
54
+ <div style="background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 12px; margin: 8px 0;">
55
+ <code style="margin: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px; color: #212529; white-space: pre-wrap; display: block;">${escapeHtml(message)}</code>
56
+ </div>`)}
57
+ ${section('System information', '#212529', `
58
+ <div style="color: #6c757d; font-size: 14px; margin: 0;">
59
+ Hostname: ${escapeHtml(os.hostname())}<br>
60
+ Platform: ${escapeHtml(`${os.platform()} ${os.release()}`)}<br>
61
+ Architecture: ${os.arch()}<br>
62
+ CPU Cores: ${cpuCount}<br>
63
+ CPU Load (1/5/15 min): ${os.loadavg().map(load => `${Math.min(100, (load / cpuCount) * 100).toFixed(1)}%`).join(' / ')}<br>
64
+ Total Memory: ${(os.totalmem() / (1024 * 1024 * 1024)).toFixed(2)} GB<br>
65
+ Free Memory: ${(os.freemem() / (1024 * 1024 * 1024)).toFixed(2)} GB${collection.host ? `<br>
66
+ Server IP: ${escapeHtml(collection.host)}` : ''}
67
+ </div>`)}
68
+ ${section('Helpful commands', '#198754', `
69
+ <ul style="list-style-type: none; padding-left: 0; margin: 0;">${commands.map(([ label, code ]) => command(label, code)).join('')}
70
+ <li style="margin: 0; padding: 0">
71
+ <strong style="display: block; margin-bottom: 0;">View deployment documentation to see how to start, stop, and restart the server:</strong>
72
+ <a href="https://github.com/OpenTermsArchive/deployment" style="color: #198754; text-decoration: none; border-bottom: 1px solid #198754;">github.com/OpenTermsArchive/deployment</a>
73
+ </li>
74
+ </ul>`)}
75
+ <div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; text-align: center;">
76
+ This is an automated message from the Open Terms Archive engine. Please do not reply to this email.
77
+ </div>
78
+ </body>
79
+ </html>
80
+ `;
81
+ }
82
+
83
+ export function createErrorMailTransports(logger, { collection, component, subject, warningSubject }) {
84
+ if (!config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
85
+ return [];
86
+ }
87
+
88
+ if (process.env.OTA_ENGINE_SMTP_PASSWORD === undefined) {
89
+ logger.warn('Environment variable "OTA_ENGINE_SMTP_PASSWORD" was not found; log emails cannot be sent');
90
+
91
+ return [];
92
+ }
93
+
94
+ const environment = process.env.NODE_ENV || 'development'; // Same default as node-config, so the prefix matches the loaded configuration
95
+ const environmentPrefix = environment == 'production' ? '' : `[${environment}] `; // Make emails sent from a developer machine recognisable at a glance
96
+
97
+ const mailerOptions = {
98
+ to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
99
+ from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
100
+ host: config.get('@opentermsarchive/engine.logger.smtp.host'),
101
+ port: config.get('@opentermsarchive/engine.logger.smtp.port'),
102
+ username: config.get('@opentermsarchive/engine.logger.smtp.username'),
103
+ password: process.env.OTA_ENGINE_SMTP_PASSWORD,
104
+ tls: true,
105
+ timeout: SMTP_TIMEOUT,
106
+ html: true,
107
+ formatter: info => formatBody({ collection, component, environmentPrefix }, info),
108
+ handleRejections: true,
109
+ };
110
+
111
+ const transports = [new MailTransportWithRetry({ ...mailerOptions, level: 'error', subject: `${environmentPrefix}${subject}` })];
112
+
113
+ if (warningSubject && config.has('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings') && config.get('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings')) { // Only callers providing a subject for warnings can send them
114
+ transports.push(new MailTransportWithRetry({
115
+ ...mailerOptions,
116
+ level: 'warn',
117
+ subject: `${environmentPrefix}${warningSubject}`,
118
+ format: winston.format(info => (info[Symbol.for('level')] === 'warn' ? info : false))(), // Winston transports receive every level at or above theirs, so errors would otherwise be emailed a second time as warnings
119
+ }));
120
+ }
121
+
122
+ return transports;
123
+ }
124
+
125
+ export function exitOnUnhandledRejection(transports, { emitter = process } = {}) {
126
+ const mailTransports = transports.filter(transport => transport instanceof MailTransportWithRetry);
127
+
128
+ emitter.on('unhandledRejection', async () => {
129
+ await new Promise(resolve => { setImmediate(resolve); }); // Winston's own listener, registered before this one, logs the rejection on the current tick; give the transports a chance to receive it before waiting for them
130
+ await Promise.race([
131
+ Promise.all(mailTransports.map(transport => transport.flush())),
132
+ new Promise(resolve => { setTimeout(resolve, SENDING_BUDGET).unref(); }),
133
+ ]);
134
+ process.exit(1);
135
+ });
136
+ }
137
+
138
+ export async function addErrorMail(logger, { component, subject, warningSubject }, { emitter = process } = {}) {
139
+ const collection = await getCollection();
140
+ const transports = createErrorMailTransports(logger, {
141
+ collection,
142
+ component,
143
+ subject: `${subject} on ${collection.id} collection`,
144
+ warningSubject: warningSubject && `${warningSubject} on ${collection.id} collection`,
145
+ });
146
+
147
+ transports.forEach(transport => logger.add(transport));
148
+ exitOnUnhandledRejection(logger.transports, { emitter });
149
+ }
@@ -0,0 +1,458 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import os from 'node:os';
3
+
4
+ import { expect, use } from 'chai';
5
+ import config from 'config';
6
+ import sinon from 'sinon';
7
+ import sinonChai from 'sinon-chai';
8
+ import winston from 'winston';
9
+
10
+ import { SENDING_BUDGET, addErrorMail, createErrorMailTransports, exitOnUnhandledRejection } from './error-mail.js';
11
+ import MailTransportWithRetry from './mail-transport-with-retry.js';
12
+
13
+ use(sinonChai);
14
+
15
+ const SEND_MAIL_ON_ERROR = {
16
+ to: 'admin@example.com',
17
+ from: 'noreply@example.com',
18
+ sendWarnings: false,
19
+ };
20
+
21
+ describe('Error mail', () => {
22
+ let configValues;
23
+ let originalPassword;
24
+ let originalEnvironment;
25
+
26
+ before(() => {
27
+ originalPassword = process.env.OTA_ENGINE_SMTP_PASSWORD;
28
+ originalEnvironment = process.env.NODE_ENV;
29
+ });
30
+
31
+ after(() => {
32
+ if (originalPassword === undefined) {
33
+ delete process.env.OTA_ENGINE_SMTP_PASSWORD;
34
+ } else {
35
+ process.env.OTA_ENGINE_SMTP_PASSWORD = originalPassword;
36
+ }
37
+ process.env.NODE_ENV = originalEnvironment;
38
+ });
39
+
40
+ beforeEach(() => {
41
+ configValues = {
42
+ '@opentermsarchive/engine.logger.sendMailOnError': SEND_MAIL_ON_ERROR,
43
+ '@opentermsarchive/engine.logger.sendMailOnError.to': SEND_MAIL_ON_ERROR.to,
44
+ '@opentermsarchive/engine.logger.sendMailOnError.from': SEND_MAIL_ON_ERROR.from,
45
+ '@opentermsarchive/engine.logger.sendMailOnError.sendWarnings': SEND_MAIL_ON_ERROR.sendWarnings,
46
+ '@opentermsarchive/engine.logger.smtp.host': 'smtp.example.com',
47
+ '@opentermsarchive/engine.logger.smtp.port': 587,
48
+ '@opentermsarchive/engine.logger.smtp.username': 'user',
49
+ '@opentermsarchive/engine.collectionPath': './test/test-declarations',
50
+ };
51
+ sinon.stub(config, 'get').callsFake(key => configValues[key]);
52
+ sinon.stub(config, 'has').callsFake(key => configValues[key] !== undefined);
53
+ process.env.NODE_ENV = 'production';
54
+ process.env.OTA_ENGINE_SMTP_PASSWORD = 'secret';
55
+ });
56
+
57
+ afterEach(() => {
58
+ sinon.restore();
59
+ });
60
+
61
+ describe('#createErrorMailTransports', () => {
62
+ const logger = { warn: () => {} };
63
+ const collection = {
64
+ id: 'test',
65
+ name: 'Test',
66
+ host: '203.0.113.1',
67
+ hostConfig: { ansible_user: 'ota' },
68
+ };
69
+ const component = 'test component';
70
+ const subject = 'Error on test collection';
71
+ const warningSubject = 'Warning on test collection';
72
+
73
+ context('when sending mail on error is disabled', () => {
74
+ beforeEach(() => {
75
+ configValues['@opentermsarchive/engine.logger.sendMailOnError'] = false;
76
+ });
77
+
78
+ it('returns no transport', () => {
79
+ expect(createErrorMailTransports(logger, { collection, component, subject, warningSubject })).to.be.empty;
80
+ });
81
+
82
+ it('does not warn', () => {
83
+ const warnSpy = sinon.spy(logger, 'warn');
84
+
85
+ createErrorMailTransports(logger, { collection, component, subject, warningSubject });
86
+
87
+ expect(warnSpy).to.not.have.been.called;
88
+ });
89
+ });
90
+
91
+ context('when the SMTP password is not defined', () => {
92
+ beforeEach(() => {
93
+ delete process.env.OTA_ENGINE_SMTP_PASSWORD;
94
+ });
95
+
96
+ it('returns no transport', () => {
97
+ expect(createErrorMailTransports(logger, { collection, component, subject, warningSubject })).to.be.empty;
98
+ });
99
+
100
+ it('warns through the logger that emails cannot be sent', () => {
101
+ const warnSpy = sinon.spy(logger, 'warn');
102
+
103
+ createErrorMailTransports(logger, { collection, component, subject, warningSubject });
104
+
105
+ expect(warnSpy).to.have.been.calledOnce;
106
+ expect(warnSpy.firstCall.args[0]).to.include('OTA_ENGINE_SMTP_PASSWORD');
107
+ });
108
+ });
109
+
110
+ context('when sending mail on error is enabled', () => {
111
+ let transports;
112
+
113
+ context('without warnings', () => {
114
+ beforeEach(() => {
115
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
116
+ });
117
+
118
+ it('returns a single transport', () => {
119
+ expect(transports).to.have.lengthOf(1);
120
+ });
121
+
122
+ it('returns a mail transport with retry', () => {
123
+ expect(transports[0]).to.be.an.instanceOf(MailTransportWithRetry);
124
+ });
125
+
126
+ it('sends errors only', () => {
127
+ expect(transports[0].level).to.equal('error');
128
+ });
129
+
130
+ it('uses the given subject', () => {
131
+ expect(transports[0].mailTransport.subject).to.equal(subject);
132
+ });
133
+
134
+ it('uses the configured recipient and sender', () => {
135
+ expect(transports[0].mailTransport.to).to.equal(SEND_MAIL_ON_ERROR.to);
136
+ expect(transports[0].mailTransport.from).to.equal(SEND_MAIL_ON_ERROR.from);
137
+ });
138
+
139
+ it('sends HTML emails', () => {
140
+ expect(transports[0].mailTransport.html).to.be.true;
141
+ });
142
+
143
+ it('handles unhandled rejections', () => {
144
+ expect(transports[0].handleRejections).to.be.true;
145
+ });
146
+ });
147
+
148
+ context('with warnings enabled and a subject for them', () => {
149
+ beforeEach(() => {
150
+ configValues['@opentermsarchive/engine.logger.sendMailOnError.sendWarnings'] = true;
151
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
152
+ });
153
+
154
+ it('returns two transports', () => {
155
+ expect(transports).to.have.lengthOf(2);
156
+ });
157
+
158
+ it('sends warnings with the second transport', () => {
159
+ expect(transports[1].level).to.equal('warn');
160
+ });
161
+
162
+ it('uses the warning subject for the second transport', () => {
163
+ expect(transports[1].mailTransport.subject).to.equal(warningSubject);
164
+ });
165
+
166
+ context('when logging through both transports', () => {
167
+ let sent;
168
+
169
+ beforeEach(() => {
170
+ sent = [];
171
+ transports.forEach((transport, index) => {
172
+ sinon.stub(transport.mailTransport, 'log').callsFake((info, callback) => {
173
+ sent.push(index);
174
+ setImmediate(() => {
175
+ transport.mailTransport.emit('logged');
176
+ callback();
177
+ });
178
+ });
179
+ });
180
+ });
181
+
182
+ it('sends errors through the error transport only', async () => {
183
+ const logger = winston.createLogger({ format: winston.format.colorize(), transports });
184
+
185
+ logger.error('boom');
186
+ await new Promise(resolve => { setTimeout(resolve, 10); });
187
+
188
+ expect(sent).to.deep.equal([0]);
189
+ });
190
+
191
+ it('sends warnings through the warning transport only', async () => {
192
+ const logger = winston.createLogger({ format: winston.format.colorize(), transports });
193
+
194
+ logger.warn('inaccessible');
195
+ await new Promise(resolve => { setTimeout(resolve, 10); });
196
+
197
+ expect(sent).to.deep.equal([1]);
198
+ });
199
+ });
200
+ });
201
+
202
+ context('with warnings enabled but no subject for them', () => {
203
+ beforeEach(() => {
204
+ configValues['@opentermsarchive/engine.logger.sendMailOnError.sendWarnings'] = true;
205
+ transports = createErrorMailTransports(logger, { collection, component, subject });
206
+ });
207
+
208
+ it('returns the error transport only', () => {
209
+ expect(transports).to.have.lengthOf(1);
210
+ expect(transports[0].level).to.equal('error');
211
+ });
212
+ });
213
+
214
+ context('with a subject for warnings but warnings not configured', () => {
215
+ beforeEach(() => {
216
+ delete configValues['@opentermsarchive/engine.logger.sendMailOnError.sendWarnings'];
217
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
218
+ });
219
+
220
+ it('returns the error transport only', () => {
221
+ expect(transports).to.have.lengthOf(1);
222
+ expect(transports[0].level).to.equal('error');
223
+ });
224
+ });
225
+
226
+ context('with a subject for warnings but warnings disabled', () => {
227
+ beforeEach(() => {
228
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
229
+ });
230
+
231
+ it('returns the error transport only', () => {
232
+ expect(transports).to.have.lengthOf(1);
233
+ expect(transports[0].level).to.equal('error');
234
+ });
235
+ });
236
+
237
+ context('email body', () => {
238
+ let formatter;
239
+ let body;
240
+
241
+ beforeEach(() => {
242
+ [{ mailTransport: { formatter } }] = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
243
+ });
244
+
245
+ context('for an error', () => {
246
+ beforeEach(() => {
247
+ body = formatter({ message: 'Error: <boom> & co', level: 'error' });
248
+ });
249
+
250
+ it('is titled as an error', () => {
251
+ expect(body).to.include('Error details');
252
+ });
253
+
254
+ it('includes the escaped message', () => {
255
+ expect(body).to.include('Error: &lt;boom&gt; &amp; co');
256
+ expect(body).to.not.include('<boom>');
257
+ });
258
+
259
+ it('names the collection', () => {
260
+ expect(body).to.include(`${collection.name} Collection`);
261
+ });
262
+
263
+ it('names the component', () => {
264
+ expect(body).to.include(`Open Terms Archive ${component} error report`);
265
+ });
266
+
267
+ it('includes the hostname', () => {
268
+ expect(body).to.include(os.hostname());
269
+ });
270
+
271
+ it('includes the command to connect to the server', () => {
272
+ expect(body).to.include(`ssh ${collection.hostConfig.ansible_user}@${collection.host}`);
273
+ });
274
+ });
275
+
276
+ context('for a warning', () => {
277
+ beforeEach(() => {
278
+ body = formatter({ message: 'Inaccessible content', level: 'warn' });
279
+ });
280
+
281
+ it('is titled as a warning', () => {
282
+ expect(body).to.include('Warning details');
283
+ });
284
+ });
285
+
286
+ context('when interpolated values contain HTML characters', () => {
287
+ beforeEach(() => {
288
+ [{ mailTransport: { formatter } }] = createErrorMailTransports(logger, { collection: { id: 'test', name: 'R&D <beta>', host: '203.0.113.1', hostConfig: { ansible_user: 'ota<x>' } }, component: 'API <v2>', subject, warningSubject });
289
+ body = formatter({ message: 'Error', level: 'error' });
290
+ });
291
+
292
+ it('escapes the collection name', () => {
293
+ expect(body).to.include('R&amp;D &lt;beta&gt; Collection');
294
+ expect(body).to.not.include('<beta>');
295
+ });
296
+
297
+ it('escapes the component', () => {
298
+ expect(body).to.include('Open Terms Archive API &lt;v2&gt; error report');
299
+ });
300
+
301
+ it('escapes the command to connect to the server', () => {
302
+ expect(body).to.include('ssh ota&lt;x&gt;@203.0.113.1');
303
+ expect(body).to.not.include('ota<x>');
304
+ });
305
+
306
+ it('escapes the commands placeholders', () => {
307
+ expect(body).to.include('pm2 logs &lt;process-name&gt;');
308
+ });
309
+ });
310
+
311
+ context('when the collection has no deployment inventory', () => {
312
+ beforeEach(() => {
313
+ [{ mailTransport: { formatter } }] = createErrorMailTransports(logger, { collection: { id: 'test', name: 'Test' }, component, subject, warningSubject });
314
+ body = formatter({ message: 'Error', level: 'error' });
315
+ });
316
+
317
+ it('omits the command to connect to the server', () => {
318
+ expect(body).to.not.include('ssh ');
319
+ });
320
+ });
321
+ });
322
+
323
+ context('outside production', () => {
324
+ beforeEach(() => {
325
+ process.env.NODE_ENV = 'staging';
326
+ configValues['@opentermsarchive/engine.logger.sendMailOnError.sendWarnings'] = true;
327
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
328
+ });
329
+
330
+ it('prefixes the error subject with the environment', () => {
331
+ expect(transports[0].mailTransport.subject).to.equal(`[staging] ${subject}`);
332
+ });
333
+
334
+ it('prefixes the warning subject with the environment', () => {
335
+ expect(transports[1].mailTransport.subject).to.equal(`[staging] ${warningSubject}`);
336
+ });
337
+
338
+ it('prefixes the title of the body with the environment', () => {
339
+ expect(transports[0].mailTransport.formatter({ message: 'Error', level: 'error' })).to.include(`[staging] Open Terms Archive ${component} error report`);
340
+ });
341
+ });
342
+
343
+ context('when the environment is not defined', () => {
344
+ beforeEach(() => {
345
+ delete process.env.NODE_ENV;
346
+ transports = createErrorMailTransports(logger, { collection, component, subject, warningSubject });
347
+ });
348
+
349
+ it('prefixes the subject with the development environment', () => {
350
+ expect(transports[0].mailTransport.subject).to.equal(`[development] ${subject}`);
351
+ });
352
+ });
353
+ });
354
+ });
355
+
356
+ describe('#addErrorMail', () => {
357
+ let logger;
358
+ let warnSpy;
359
+
360
+ beforeEach(() => {
361
+ logger = winston.createLogger({ transports: [new winston.transports.Console({ silent: true })], exitOnError: false });
362
+ warnSpy = sinon.spy(logger, 'warn');
363
+ });
364
+
365
+ context('when sending mail on error is enabled with warnings', () => {
366
+ beforeEach(async () => {
367
+ configValues['@opentermsarchive/engine.logger.sendMailOnError.sendWarnings'] = true;
368
+ await addErrorMail(logger, { component: 'engine', subject: 'Server error', warningSubject: 'Inaccessible content' }, { emitter: new EventEmitter() });
369
+ });
370
+
371
+ it('adds the mail transports to the logger', () => {
372
+ expect(logger.transports).to.have.lengthOf(3);
373
+ expect(logger.transports[1]).to.be.an.instanceOf(MailTransportWithRetry);
374
+ expect(logger.transports[2]).to.be.an.instanceOf(MailTransportWithRetry);
375
+ });
376
+
377
+ it('names the collection in the subjects', () => {
378
+ expect(logger.transports[1].mailTransport.subject).to.equal('Server error on test collection');
379
+ expect(logger.transports[2].mailTransport.subject).to.equal('Inaccessible content on test collection');
380
+ });
381
+ });
382
+
383
+ context('when the SMTP password is not defined', () => {
384
+ beforeEach(async () => {
385
+ delete process.env.OTA_ENGINE_SMTP_PASSWORD;
386
+ await addErrorMail(logger, { component: 'engine', subject: 'Server error' }, { emitter: new EventEmitter() });
387
+ });
388
+
389
+ it('warns through the logger', () => {
390
+ expect(warnSpy).to.have.been.calledOnce;
391
+ expect(warnSpy.firstCall.args[0]).to.include('OTA_ENGINE_SMTP_PASSWORD');
392
+ });
393
+
394
+ it('adds no transport', () => {
395
+ expect(logger.transports).to.have.lengthOf(1);
396
+ });
397
+ });
398
+ });
399
+
400
+ describe('#exitOnUnhandledRejection', () => {
401
+ let emitter;
402
+ let processExitStub;
403
+ let mailTransport;
404
+ let sent;
405
+
406
+ const tick = () => new Promise(resolve => { setImmediate(resolve); });
407
+
408
+ beforeEach(() => {
409
+ emitter = new EventEmitter();
410
+ processExitStub = sinon.stub(process, 'exit');
411
+ mailTransport = Object.create(MailTransportWithRetry.prototype);
412
+ mailTransport.flush = () => new Promise(resolve => { sent = resolve; });
413
+ exitOnUnhandledRejection([ new winston.transports.Console({ silent: true }), mailTransport ], { emitter });
414
+ });
415
+
416
+ context('when emails are sent', () => {
417
+ beforeEach(() => {
418
+ emitter.emit('unhandledRejection', new Error('boom'));
419
+ });
420
+
421
+ it('waits for pending emails before exiting', async () => {
422
+ await tick();
423
+ await tick();
424
+
425
+ expect(processExitStub).to.not.have.been.called;
426
+ });
427
+
428
+ it('exits with a failure code once emails are sent', async () => {
429
+ await tick();
430
+ sent();
431
+ await tick();
432
+
433
+ expect(processExitStub).to.have.been.calledOnceWithExactly(1);
434
+ });
435
+ });
436
+
437
+ context('when emails cannot be sent', () => {
438
+ let clock;
439
+
440
+ beforeEach(() => {
441
+ clock = sinon.useFakeTimers();
442
+ emitter.emit('unhandledRejection', new Error('boom'));
443
+ });
444
+
445
+ afterEach(() => {
446
+ clock.restore();
447
+ });
448
+
449
+ it('exits with a failure code after one sending attempt', async () => {
450
+ await clock.tickAsync(SENDING_BUDGET - 1);
451
+ expect(processExitStub).to.not.have.been.called;
452
+
453
+ await clock.tickAsync(1);
454
+ expect(processExitStub).to.have.been.calledOnceWithExactly(1);
455
+ });
456
+ });
457
+ });
458
+ });
@@ -1,17 +1,11 @@
1
- import os from 'os';
2
-
3
1
  import config from 'config';
4
2
  import winston from 'winston';
5
3
 
6
- import { getCollection } from '../archivist/collection/index.js';
7
-
8
- import MailTransportWithRetry from './mail-transport-with-retry.js';
4
+ import { addErrorMail } from './error-mail.js';
9
5
  import { formatDuration } from './utils.js';
10
6
 
11
7
  const { combine, timestamp, printf, colorize } = winston.format;
12
8
 
13
- const collection = await getCollection();
14
-
15
9
  const alignedWithColorsAndTime = combine(
16
10
  colorize(),
17
11
  timestamp({ format: 'YYYY-MM-DDTHH:mm:ssZ' }),
@@ -28,141 +22,15 @@ const alignedWithColorsAndTime = combine(
28
22
  }),
29
23
  );
30
24
 
31
- const consoleTransport = new winston.transports.Console({ silent: process.env.NODE_ENV === 'test' });
32
-
33
- const transports = [consoleTransport];
25
+ const consoleTransport = new winston.transports.Console({ silent: process.env.NODE_ENV === 'test', handleRejections: true });
34
26
 
35
27
  const logger = winston.createLogger({
36
28
  format: alignedWithColorsAndTime,
37
- transports,
38
- rejectionHandlers: transports,
39
- exitOnError: true,
29
+ transports: [consoleTransport],
30
+ exitOnError: false,
40
31
  });
41
32
 
42
- logger.on('error', err => {
43
- if ('smtp' in err) { // Check if err has an `smtp` property, even if it's undefined
44
- logger.warn({ message: `Uncaught exception from SMTP mailer detected and treated as an operational error; process will continue running:\n${err.stack}` });
45
-
46
- return; // Prevent process exit
47
- }
48
-
49
- return process.exit(1); // Exit process for other errors
50
- });
51
-
52
- if (config.get('@opentermsarchive/engine.logger.sendMailOnError')) {
53
- if (process.env.OTA_ENGINE_SMTP_PASSWORD === undefined) {
54
- logger.warn('Environment variable "OTA_ENGINE_SMTP_PASSWORD" was not found; log emails cannot be sent');
55
- } else {
56
- const mailerOptions = {
57
- to: config.get('@opentermsarchive/engine.logger.sendMailOnError.to'),
58
- from: config.get('@opentermsarchive/engine.logger.sendMailOnError.from'),
59
- host: config.get('@opentermsarchive/engine.logger.smtp.host'),
60
- port: config.get('@opentermsarchive/engine.logger.smtp.port'),
61
- username: config.get('@opentermsarchive/engine.logger.smtp.username'),
62
- password: process.env.OTA_ENGINE_SMTP_PASSWORD,
63
- tls: true,
64
- timeout: 60 * 1000,
65
- html: false,
66
- formatter({ message, level }) {
67
- const isError = level.includes('error');
68
- const titleColor = isError ? '#dc3545' : '#ffc107';
69
- const titleText = isError ? 'Error details' : 'Warning details';
70
-
71
- return `
72
- <!DOCTYPE html>
73
- <html lang="en">
74
- <head>
75
- <meta charset="utf-8">
76
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
77
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
78
- <title>OTA Error Report</title>
79
- </head>
80
- <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333333; max-width: 800px; margin: 0 auto; padding: 0px 20px 20px 20px;">
81
- <h1 style="color: #212529; font-size: 24px; margin: 10px 0; text-align: center; padding-bottom: 10px;">Open Terms Archive engine error report — ${collection.name} Collection</h1>
82
-
83
- <div style="background-color: #f8f9fa; border-radius: 8px; padding: 15px; margin-bottom: 0;">
84
- <h2 style="color: ${titleColor}; margin: 0 0 0 0; font-size: 20px; border-bottom: 2px solid ${titleColor}; padding-bottom: 8px;">${titleText}</h2>
85
- <div style="background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 12px; margin: 8px 0;">
86
- <code style="maring: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 14px; color: #212529; white-space: pre-wrap; display: block;">${message}</code>
87
- </div>
88
- </div>
89
-
90
- <div style="background-color: #f8f9fa; border-radius: 8px; padding: 15px; margin-bottom: 0;">
91
- <h2 style="color: #212529; margin: 0 0 0 0; font-size: 20px; border-bottom: 2px solid #212529; padding-bottom: 8px;">System information</h2>
92
- <div style="color: #6c757d; font-size: 14px; margin: 0;">
93
- Hostname: ${os.hostname()}<br>
94
- Platform: ${os.platform()} ${os.release()}<br>
95
- Architecture: ${os.arch()}<br>
96
- CPU Cores: ${os.cpus().length}<br>
97
- CPU Load (1/5/15 min): ${os.loadavg().map(load =>
98
- `${Math.min(100, (load / os.cpus().length) * 100).toFixed(1)}%`).join(' / ')}<br>
99
- Total Memory: ${(os.totalmem() / (1024 * 1024 * 1024)).toFixed(2)} GB<br>
100
- Free Memory: ${(os.freemem() / (1024 * 1024 * 1024)).toFixed(2)} GB${collection.host ? `<br>
101
- Server IP: ${collection.host}` : ''}
102
- </div>
103
- </div>
104
-
105
- <div style="background-color: #f8f9fa; border-radius: 8px; padding: 15px; margin-bottom: 0;">
106
- <h2 style="color: #198754; margin: 0 0 0 0; font-size: 20px; border-bottom: 2px solid #198754; padding-bottom: 8px;">Helpful commands</h2>
107
- <ul style="list-style-type: none; padding-left: 0; margin: 0;">
108
- ${collection.host && collection.hostConfig?.ansible_user ? `
109
- <li style="margin: 0; padding: 0">
110
- <strong style="display: block; margin-bottom: 0;">Connect to the server:</strong>
111
- <code style="maring: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 6px 10px; display: inline-block; color: #212529; cursor: text; user-select: all; -webkit-user-select: all; -moz-user-select: all; -ms-user-select: all;">ssh ${collection.hostConfig.ansible_user}@${collection.host}</code>
112
- </li>` : ''}
113
-
114
- <li style="margin: 0; padding: 0">
115
- <strong style="display: block; margin-bottom: 0;">List processes on the server:</strong>
116
- <code style="maring: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 6px 10px; display: inline-block; color: #212529; cursor: text; user-select: all; -webkit-user-select: all; -moz-user-select: all; -ms-user-select: all;">pm2 list</code>
117
- </li>
118
-
119
- <li style="margin: 0; padding: 0">
120
- <strong style="display: block; margin-bottom: 0;">View the logs on the server:</strong>
121
- <code style="maring: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 6px 10px; display: inline-block; color: #212529; cursor: text; user-select: all; -webkit-user-select: all; -moz-user-select: all; -ms-user-select: all;">pm2 logs &lt;process-name&gt;</code>
122
- </li>
123
-
124
- <li style="margin: 0; padding: 0">
125
- <strong style="display: block; margin-bottom: 0;">View additional logging options:</strong>
126
- <code style="maring: 0; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 4px; padding: 6px 10px; display: inline-block; color: #212529; cursor: text; user-select: all; -webkit-user-select: all; -moz-user-select: all; -ms-user-select: all;">pm2 logs &lt;process-name&gt; --help</code>
127
- </li>
128
-
129
- <li style="margin: 0; padding: 0">
130
- <strong style="display: block; margin-bottom: 0;">View deployment documentation to see how to start, stop, and restart the server:</strong>
131
- <a href="https://github.com/OpenTermsArchive/deployment" style="color: #198754; text-decoration: none; border-bottom: 1px solid #198754;">github.com/OpenTermsArchive/deployment</a>
132
- </li>
133
- </ul>
134
- </div>
135
-
136
- <div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; text-align: center;">
137
- This is an automated message from the Open Terms Archive engine. Please do not reply to this email.
138
- </div>
139
- </body>
140
- </html>
141
- `;
142
- },
143
- };
144
-
145
- transports.push(new MailTransportWithRetry({
146
- ...mailerOptions,
147
- level: 'error',
148
- subject: `Server error on ${collection.id} collection`,
149
- }));
150
-
151
- if (config.get('@opentermsarchive/engine.logger.sendMailOnError.sendWarnings')) {
152
- transports.push(new MailTransportWithRetry({
153
- ...mailerOptions,
154
- level: 'warn',
155
- subject: `Inaccessible content on ${collection.id} collection`,
156
- }));
157
- }
158
- }
159
- }
160
-
161
- logger.configure({
162
- transports,
163
- rejectionHandlers: transports,
164
- exitOnError: true,
165
- });
33
+ await addErrorMail(logger, { component: 'engine', subject: 'Server error', warningSubject: 'Inaccessible content' });
166
34
 
167
35
  let recordedSnapshotsCount;
168
36
  let recordedVersionsCount;
@@ -1,5 +1,3 @@
1
- import { once } from 'node:events';
2
-
3
1
  import async from 'async';
4
2
  import winston from 'winston';
5
3
 
@@ -21,25 +19,52 @@ class MailTransportWithRetry extends winston.Transport {
21
19
  constructor(options) {
22
20
  super(options);
23
21
  this.mailTransport = new winston.transports.Mail(options);
22
+ this.pending = new Set();
24
23
  }
25
24
 
26
- async log(info, callback) {
27
- try {
28
- await async.retry(RETRY_OPTIONS, async () => {
29
- const result = Promise.race([
30
- once(this.mailTransport, 'logged'),
31
- once(this.mailTransport, 'error').then(([err]) => { throw err; }),
32
- ]);
25
+ log(info, callback) {
26
+ callback(); // Winston delivers each log to every transport in sequence, so waiting for the SMTP retries here would hold back the other transports too
33
27
 
34
- this.mailTransport.log(info, () => {});
28
+ const sending = this.send(info).finally(() => this.pending.delete(sending));
35
29
 
36
- return result;
37
- });
30
+ this.pending.add(sending);
31
+
32
+ return sending;
33
+ }
34
+
35
+ async send(info) {
36
+ try {
37
+ await async.retry(RETRY_OPTIONS, async () => { await this.attempt(info); }); // The task must be an async function for async.retry to await it rather than wait for a callback
38
38
  } catch (error) {
39
- console.warn(`SMTP mail sending failed after ${RETRY_OPTIONS.times} attempts: ${error.message}`);
40
- this.emit('error', error);
39
+ console.warn(`SMTP mail sending failed after ${RETRY_OPTIONS.times} attempts; giving up on this email:\n${error.stack}`);
41
40
  }
42
- callback();
41
+ }
42
+
43
+ attempt(info) {
44
+ return new Promise((resolve, reject) => {
45
+ const removeListeners = () => {
46
+ this.mailTransport.off('logged', onLogged);
47
+ this.mailTransport.off('error', onError);
48
+ };
49
+
50
+ function onLogged() {
51
+ removeListeners();
52
+ resolve();
53
+ }
54
+
55
+ function onError(error) {
56
+ removeListeners();
57
+ reject(error);
58
+ }
59
+
60
+ this.mailTransport.once('logged', onLogged);
61
+ this.mailTransport.once('error', onError);
62
+ this.mailTransport.log(info, () => {});
63
+ });
64
+ }
65
+
66
+ async flush() {
67
+ await Promise.allSettled(this.pending);
43
68
  }
44
69
  }
45
70
 
@@ -29,6 +29,25 @@ describe('MailTransportWithRetry', () => {
29
29
  clock.restore();
30
30
  });
31
31
 
32
+ describe('#flush', () => {
33
+ it('resolves once pending emails are sent', async () => {
34
+ let flushed = false;
35
+
36
+ transport.log({ message: 'test' }, () => {});
37
+
38
+ const flushPromise = transport.flush().then(() => { flushed = true; });
39
+
40
+ await clock.tickAsync(0);
41
+ expect(flushed).to.be.false;
42
+
43
+ mockMailTransport.emit('logged');
44
+ await flushPromise;
45
+
46
+ expect(flushed).to.be.true;
47
+ expect(transport.pending).to.be.empty;
48
+ });
49
+ });
50
+
32
51
  describe('#log', () => {
33
52
  context('when email is sent successfully on first attempt', () => {
34
53
  it('calls callback without error', async () => {
@@ -62,6 +81,24 @@ describe('MailTransportWithRetry', () => {
62
81
 
63
82
  expect(consoleWarnStub).not.to.have.been.called;
64
83
  });
84
+
85
+ it('calls callback before the email is sent', () => {
86
+ const callback = sinon.spy();
87
+
88
+ transport.log({ message: 'test' }, callback);
89
+
90
+ expect(callback).to.have.been.calledOnce;
91
+ });
92
+
93
+ it('removes its listeners from the mail transport', async () => {
94
+ const logPromise = transport.log({ message: 'test' }, () => {});
95
+
96
+ mockMailTransport.emit('logged');
97
+ await logPromise;
98
+
99
+ expect(mockMailTransport.listenerCount('logged')).to.equal(0);
100
+ expect(mockMailTransport.listenerCount('error')).to.equal(0);
101
+ });
65
102
  });
66
103
 
67
104
  context('when email fails then succeeds on retry', () => {
@@ -98,7 +135,7 @@ describe('MailTransportWithRetry', () => {
98
135
  });
99
136
 
100
137
  context('when email fails after all retry attempts', () => {
101
- it('emits error event after all retries are exhausted', async () => {
138
+ it('does not emit an error event after all retries are exhausted', async () => {
102
139
  const errorHandler = sinon.spy();
103
140
 
104
141
  transport.on('error', errorHandler);
@@ -117,15 +154,12 @@ describe('MailTransportWithRetry', () => {
117
154
  mockMailTransport.emit('error', new Error('SMTP timeout'));
118
155
  await logPromise;
119
156
 
120
- expect(errorHandler).to.have.been.calledOnce;
121
- expect(errorHandler.firstCall.args[0].message).to.equal('SMTP timeout');
157
+ expect(errorHandler).not.to.have.been.called;
122
158
  });
123
159
 
124
160
  it('calls callback even after failure', async () => {
125
161
  const callback = sinon.spy();
126
162
 
127
- transport.on('error', () => {}); // Prevent unhandled error
128
-
129
163
  const logPromise = transport.log({ message: 'test' }, callback);
130
164
 
131
165
  mockMailTransport.emit('error', new Error('SMTP timeout'));
@@ -144,8 +178,6 @@ describe('MailTransportWithRetry', () => {
144
178
  });
145
179
 
146
180
  it('logs final failure warning', async () => {
147
- transport.on('error', () => {}); // Prevent unhandled error
148
-
149
181
  const logPromise = transport.log({ message: 'test' }, () => {});
150
182
 
151
183
  mockMailTransport.emit('error', new Error('SMTP timeout'));
@@ -161,6 +193,26 @@ describe('MailTransportWithRetry', () => {
161
193
  await logPromise;
162
194
 
163
195
  expect(consoleWarnStub.lastCall.args[0]).to.include(`failed after ${RETRY_DELAYS.length + 1} attempts`);
196
+ expect(consoleWarnStub.lastCall.args[0]).to.include('Error: SMTP timeout');
197
+ });
198
+
199
+ it('removes its listeners from the mail transport', async () => {
200
+ const logPromise = transport.log({ message: 'test' }, () => {});
201
+
202
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
203
+ await clock.tickAsync(RETRY_DELAYS[0]);
204
+
205
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
206
+ await clock.tickAsync(RETRY_DELAYS[1]);
207
+
208
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
209
+ await clock.tickAsync(RETRY_DELAYS[2]);
210
+
211
+ mockMailTransport.emit('error', new Error('SMTP timeout'));
212
+ await logPromise;
213
+
214
+ expect(mockMailTransport.listenerCount('logged')).to.equal(0);
215
+ expect(mockMailTransport.listenerCount('error')).to.equal(0);
164
216
  });
165
217
  });
166
218