@opentermsarchive/engine 15.1.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 +1 -1
- package/src/archivist/extract/exposedFilters.js +32 -1
- package/src/archivist/extract/exposedFilters.test.js +20 -0
- package/src/archivist/services/index.js +1 -1
- package/src/archivist/services/index.test.js +14 -2
- package/src/collection-api/logger.js +5 -24
- package/src/logger/error-mail.js +149 -0
- package/src/logger/error-mail.test.js +458 -0
- package/src/logger/index.js +5 -137
- package/src/logger/mail-transport-with-retry.js +40 -15
- package/src/logger/mail-transport-with-retry.test.js +59 -7
package/package.json
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
const DEFAULT_TRACKING_QUERY_PARAMS = Object.freeze([
|
|
2
|
+
'utm_source', // Campaign parameters, see https://en.wikipedia.org/wiki/UTM_parameters
|
|
3
|
+
'utm_medium',
|
|
4
|
+
'utm_campaign',
|
|
5
|
+
'utm_term',
|
|
6
|
+
'utm_content',
|
|
7
|
+
'fbclid', // Click and user identifiers, see https://github.com/privacytests/privacytests/blob/dda473a462e5c37f9f2c8b2367fbc5f834796be4/live/results.js#L31-L65
|
|
8
|
+
'gclid',
|
|
9
|
+
'msclkid',
|
|
10
|
+
'mc_eid',
|
|
11
|
+
'dclid',
|
|
12
|
+
'oly_anon_id',
|
|
13
|
+
'oly_enc_id',
|
|
14
|
+
'_openstat',
|
|
15
|
+
'vero_conv',
|
|
16
|
+
'vero_id',
|
|
17
|
+
'wickedid',
|
|
18
|
+
'yclid',
|
|
19
|
+
'__s',
|
|
20
|
+
'rb_clickid',
|
|
21
|
+
's_cid',
|
|
22
|
+
'ml_subscriber',
|
|
23
|
+
'ml_subscriber_hash',
|
|
24
|
+
'_hsenc',
|
|
25
|
+
'__hssc',
|
|
26
|
+
'__hstc',
|
|
27
|
+
'__hsfp',
|
|
28
|
+
'hsCtaTracking',
|
|
29
|
+
'mkt_tok',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function removeQueryParams(webPageDOM, paramsToRemove = DEFAULT_TRACKING_QUERY_PARAMS) {
|
|
2
33
|
const normalizedParams = Array.isArray(paramsToRemove) ? paramsToRemove : [paramsToRemove];
|
|
3
34
|
|
|
4
35
|
if (!normalizedParams.length) {
|
|
@@ -51,6 +51,26 @@ describe('exposedFilters', () => {
|
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
describe('without parameters', () => {
|
|
55
|
+
let link;
|
|
56
|
+
|
|
57
|
+
before(() => {
|
|
58
|
+
link = webPageDOM.createElement('a');
|
|
59
|
+
link.setAttribute('href', 'https://example.com/page?utm_source=newsletter&utm_campaign=spring&fbclid=abc&gclid=def&mkt_tok=ghi&keep=value');
|
|
60
|
+
webPageDOM.body.appendChild(link);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
after(() => {
|
|
64
|
+
link.remove();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('removes well-known tracking parameters', () => {
|
|
68
|
+
removeQueryParams(webPageDOM);
|
|
69
|
+
|
|
70
|
+
expect(link.getAttribute('href')).to.equal('https://example.com/page?keep=value');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
54
74
|
describe('with string parameter', () => {
|
|
55
75
|
let link;
|
|
56
76
|
|
|
@@ -106,7 +106,7 @@ function createWrappedFilter(baseFunction, filterName, filterParams) {
|
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
if (filterParams) {
|
|
109
|
+
if (filterParams || exposedFilters[filterName]) { // Built-in filters always receive their parameters before the context, even when none are declared
|
|
110
110
|
const wrappedFilter = (webPageDOM, context) => baseFunction(webPageDOM, filterParams, context);
|
|
111
111
|
|
|
112
112
|
Object.defineProperty(wrappedFilter, 'name', { value: filterName });
|
|
@@ -6,6 +6,7 @@ import sinon from 'sinon';
|
|
|
6
6
|
import sinonChai from 'sinon-chai';
|
|
7
7
|
|
|
8
8
|
import expectedServices from '../../../test/fixtures/services.js';
|
|
9
|
+
import createWebPageDOM from '../extract/dom.js';
|
|
9
10
|
import * as exposedFilters from '../extract/exposedFilters.js';
|
|
10
11
|
|
|
11
12
|
import Service from './service.js';
|
|
@@ -272,7 +273,18 @@ describe('Services', () => {
|
|
|
272
273
|
const filterName = filterNames[0];
|
|
273
274
|
const result = getServiceFilters({}, [filterName]);
|
|
274
275
|
|
|
275
|
-
expect(result).to.
|
|
276
|
+
expect(result).to.have.length(1);
|
|
277
|
+
expect(result[0]).to.be.a('function');
|
|
278
|
+
expect(result[0].name).to.equal(filterName);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('calls exposedFilters with their default parameters when declared by string name', () => {
|
|
282
|
+
const webPageDOM = createWebPageDOM('<!DOCTYPE html><html><body><a href="https://example.com/page?fbclid=abc&keep=value"></a></body></html>', 'https://example.com');
|
|
283
|
+
const [removeQueryParams] = getServiceFilters({}, ['removeQueryParams']);
|
|
284
|
+
|
|
285
|
+
removeQueryParams(webPageDOM, { fetch: 'https://example.com', select: [], remove: [], filter: ['removeQueryParams'] });
|
|
286
|
+
|
|
287
|
+
expect(webPageDOM.querySelector('a').getAttribute('href')).to.equal('https://example.com/page?keep=value');
|
|
276
288
|
});
|
|
277
289
|
|
|
278
290
|
it('returns filters from serviceFilters by string name', () => {
|
|
@@ -371,7 +383,7 @@ describe('Services', () => {
|
|
|
371
383
|
expect(sourceDocument.filters).to.be.an('array');
|
|
372
384
|
expect(sourceDocument.filters).to.have.length(2);
|
|
373
385
|
expect(sourceDocument.filters[0]).to.be.a('function');
|
|
374
|
-
expect(sourceDocument.filters[0]).to.equal(
|
|
386
|
+
expect(sourceDocument.filters[0].name).to.equal(realFilterNames[0]);
|
|
375
387
|
expect(sourceDocument.filters[1]).to.be.a('function');
|
|
376
388
|
expect(sourceDocument.filters[1].name).to.equal('removePrintButton');
|
|
377
389
|
});
|
|
@@ -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
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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: <boom> & 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&D <beta> Collection');
|
|
294
|
+
expect(body).to.not.include('<beta>');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('escapes the component', () => {
|
|
298
|
+
expect(body).to.include('Open Terms Archive API <v2> error report');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('escapes the command to connect to the server', () => {
|
|
302
|
+
expect(body).to.include('ssh ota<x>@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 <process-name>');
|
|
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
|
+
});
|
package/src/logger/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
39
|
-
exitOnError: true,
|
|
29
|
+
transports: [consoleTransport],
|
|
30
|
+
exitOnError: false,
|
|
40
31
|
});
|
|
41
32
|
|
|
42
|
-
logger
|
|
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 <process-name></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 <process-name> --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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
28
|
+
const sending = this.send(info).finally(() => this.pending.delete(sending));
|
|
35
29
|
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
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.
|
|
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
|
|