1 | import mkdirp from 'mkdirp';
|
2 | import { createWriteStream, fstatSync, openSync, renameSync } from 'fs';
|
3 | import { dirname } from 'path';
|
4 | import words from './words';
|
5 | import colors from 'colors';
|
6 | import findUp from 'find-up';
|
7 | import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
8 | import nodemailer from 'nodemailer';
|
9 |
|
10 | export class BaseException<T> extends Error {
|
11 | kind: string;
|
12 | constructor(public name: string, public json = {} as T) {
|
13 | super();
|
14 | this.kind = new.target.name;
|
15 | }
|
16 | }
|
17 | export class ClientException<T = object> extends BaseException<T> {}
|
18 | export class ExternalException<T = object> extends BaseException<T> {}
|
19 | export class Exception<T = object> extends BaseException<T> {}
|
20 |
|
21 | type Levels = keyof typeof levels;
|
22 |
|
23 | type LoggerStreamConfig =
|
24 | | { level: Levels; type: 'file'; file: string; rotate?: 'daily' }
|
25 | | { level: Levels; type: 'stdout' }
|
26 | | {
|
27 | level: Levels;
|
28 | type: 'email';
|
29 | options: SMTPTransport.Options;
|
30 | from: string;
|
31 | to: string;
|
32 | subject: { start: string; error: string };
|
33 | };
|
34 | export interface LoggerSettings {
|
35 | streams: LoggerStreamConfig[];
|
36 | }
|
37 |
|
38 | export class Logger {
|
39 | protected streams: LoggerStream[] = [];
|
40 | constructor(settings: LoggerSettings) {
|
41 | this.setSettings(settings);
|
42 | }
|
43 |
|
44 | protected setSettings(settings: LoggerSettings) {
|
45 | for (const streamConfig of settings.streams) {
|
46 | const level = levels[streamConfig.level];
|
47 | if (streamConfig.type === 'file') {
|
48 | logger.streams.push(new FileStream(level, streamConfig));
|
49 | }
|
50 | if (streamConfig.type === 'stdout') {
|
51 | logger.streams.push(new StdoutStream(level, streamConfig));
|
52 | }
|
53 | if (streamConfig.type === 'email') {
|
54 | logger.streams.push(new EmailStream(level, streamConfig));
|
55 | }
|
56 | }
|
57 | }
|
58 |
|
59 | protected log(type: Levels, name: string, json?: object) {
|
60 | if (this.streams.length === 0) throw new Exception('Empty logger streams');
|
61 | if (json === undefined) json = {};
|
62 | if (!(json instanceof Object)) json = { raw: json };
|
63 | const id = words[Math.floor(words.length * Math.random())];
|
64 | const parentId = '';
|
65 | const date = new Date();
|
66 | for (const stream of this.streams) {
|
67 | if (levels[type] <= stream.level) {
|
68 | stream.write(id, parentId, date, type, name, json);
|
69 | }
|
70 | }
|
71 | }
|
72 |
|
73 | info(name: string, json?: object) {
|
74 | return this.log('info', name, json);
|
75 | }
|
76 | clientError(name: string, json?: object) {
|
77 | return this.log('clientError', name, json);
|
78 | }
|
79 | warn(name: string, json?: object) {
|
80 | return this.log('warn', name, json);
|
81 | }
|
82 | trace(name: string, json?: object) {
|
83 | return this.log('trace', name, json);
|
84 | }
|
85 | error(name: string | Error, json?: object) {
|
86 | if (name instanceof Error) {
|
87 | const error = name;
|
88 | if (error instanceof BaseException) {
|
89 | if (error.kind === ClientException.name) {
|
90 | return this.clientError(error.name, error);
|
91 | }
|
92 | if (error.kind === ExternalException.name) {
|
93 | return this.external(error.name, error);
|
94 | }
|
95 | if (error.kind === Exception.name) {
|
96 | return this.log('error', error.name, error);
|
97 | }
|
98 | }
|
99 | return this.log('error', error.constructor.name, error);
|
100 | }
|
101 | if (typeof name !== 'string') {
|
102 | return this.log('error', 'Raw error', (name as {}) instanceof Object ? name : { error: name });
|
103 | }
|
104 | return this.log('error', name, json);
|
105 | }
|
106 | external(name: string, json?: object) {
|
107 | return this.log('external', name, json);
|
108 | }
|
109 | }
|
110 |
|
111 | class LoggerOpened extends Logger {
|
112 | setSettings(_settings: LoggerSettings) {}
|
113 | }
|
114 |
|
115 | abstract class LoggerStream {
|
116 | constructor(public level: number) {}
|
117 | abstract write(id: string, parentId: string, date: Date, type: Levels, name: string, json: object): void;
|
118 | }
|
119 | class EmailStream extends LoggerStream {
|
120 | constructor(
|
121 | level: number,
|
122 | public options: {
|
123 | options: SMTPTransport.Options;
|
124 | from: string;
|
125 | to: string;
|
126 | subject: { start: string; error: string };
|
127 | },
|
128 | ) {
|
129 | super(level);
|
130 | this.sendMail(this.options.subject.start, '');
|
131 | }
|
132 | transport = nodemailer.createTransport(this.options.options);
|
133 | lastSendedAt = new Date(0);
|
134 | previousLogsCount = 0;
|
135 |
|
136 | sendMail(subject: string, text: string) {
|
137 | this.transport
|
138 | .sendMail({
|
139 | to: this.options.to,
|
140 | from: this.options.from,
|
141 | subject,
|
142 | text,
|
143 | })
|
144 | .catch(err => logger.error(err));
|
145 | }
|
146 |
|
147 | write(_id: string, _parentId: string, date: Date, type: Levels, name: string, json: object): void {
|
148 | if (Date.now() - this.lastSendedAt.getTime() < 3_600_000) {
|
149 | this.previousLogsCount++;
|
150 | return;
|
151 | }
|
152 | this.sendMail(
|
153 | this.options.subject.error,
|
154 | `${
|
155 | this.previousLogsCount > 0 ? `Prev errors count: ${this.previousLogsCount}\n` : ''
|
156 | }${date.toISOString()} ${type} ${name} ${JSON.stringify(json, jsonReplacer, 2)}`,
|
157 | );
|
158 | this.lastSendedAt = new Date();
|
159 | this.previousLogsCount = 0;
|
160 | }
|
161 | }
|
162 | class FileStream extends LoggerStream {
|
163 | createdAt: Date;
|
164 | stream: NodeJS.WritableStream;
|
165 | rotate: 'daily' | 'never';
|
166 | fileName: string;
|
167 |
|
168 | constructor(level: number, public options: { file: string; rotate?: 'daily' }) {
|
169 | super(level);
|
170 | mkdirp.sync(dirname(options.file));
|
171 | let createdAt = new Date();
|
172 | try {
|
173 | createdAt = fstatSync(openSync(options.file, 'r')).ctime;
|
174 | } catch (e) {}
|
175 | this.stream = createWriteStream(options.file, { flags: 'a' });
|
176 | this.createdAt = createdAt;
|
177 | this.rotate = options.rotate || 'never';
|
178 | this.fileName = options.file;
|
179 | }
|
180 |
|
181 | protected selectFile() {
|
182 | const d = new Date();
|
183 | if (this.rotate === 'daily') {
|
184 | const d2 = this.createdAt;
|
185 | if (d.getDate() !== d2.getDate() || d.getMonth() !== d2.getMonth() || d.getFullYear() !== d2.getFullYear()) {
|
186 | this.stream.end();
|
187 | const historyName =
|
188 | this.fileName.replace(/\.log$/, '') + '_' + this.createdAt.toISOString().split('T')[0] + '.log';
|
189 | renameSync(this.fileName, historyName);
|
190 | this.stream = createWriteStream(this.fileName);
|
191 | this.createdAt = new Date();
|
192 | }
|
193 | }
|
194 | }
|
195 |
|
196 | write(id: string, parentId: string, date: Date, type: Levels, name: string, json: object) {
|
197 | this.selectFile();
|
198 | const str = JSON.stringify([id, parentId, date, type, name, json], jsonReplacer) + '\n';
|
199 | this.stream.write(str);
|
200 | }
|
201 | }
|
202 | class StdoutStream extends LoggerStream {
|
203 | constructor(level: number, _options: {}) {
|
204 | super(level);
|
205 | }
|
206 | write(_id: string, _parentId: string, date: Date, type: Levels, name: string, json: object) {
|
207 | let fn = colors.black;
|
208 | if (type === 'error') fn = colors.red.bold;
|
209 | if (type === 'info') fn = colors.cyan;
|
210 | if (type === 'warn') fn = colors.yellow;
|
211 | if (type === 'trace') fn = colors.gray;
|
212 | if (type === 'external') fn = colors.magenta;
|
213 | if (type === 'clientError') fn = colors.green;
|
214 | const dtS =
|
215 | ('0' + date.getHours()).substr(-2) +
|
216 | ':' +
|
217 | ('0' + date.getMinutes()).substr(-2) +
|
218 | ':' +
|
219 | ('0' + date.getSeconds()).substr(-2);
|
220 | process.stdout.write(
|
221 | colors.gray(dtS + ' ' + type + ' ') + fn(name + ' ') + colors.gray(JSON.stringify(json, jsonReplacer, 2) + '\n'),
|
222 | );
|
223 | }
|
224 | }
|
225 |
|
226 | function jsonReplacer(_key: string, value: unknown) {
|
227 | if (value instanceof Error) {
|
228 | const stack = cleanStackTrace(value.stack);
|
229 | if (value instanceof BaseException) {
|
230 | return { name: value.name, stack, json: value.json };
|
231 | }
|
232 | return { ...value, error: value.message, stack };
|
233 | }
|
234 | if (value instanceof Object) {
|
235 | if ('request' in value && 'headers' in value && 'body' in value && 'statusCode' in value) {
|
236 | return { __type: 'responseObject' };
|
237 | }
|
238 | if ('method' in value && 'uri' in value && 'headers' in value) {
|
239 | return { __type: 'requestObject' };
|
240 | }
|
241 | if (value instanceof Promise) {
|
242 | return { __type: 'promise' };
|
243 | }
|
244 | if (value instanceof Buffer) {
|
245 | return { __type: 'buffer' };
|
246 | }
|
247 | }
|
248 | return value;
|
249 | }
|
250 |
|
251 | const levels = {
|
252 | error: 0,
|
253 | warn: 1,
|
254 | external: 2,
|
255 | info: 3,
|
256 | clientError: 4,
|
257 | trace: 5,
|
258 | };
|
259 |
|
260 | const packageJsonFile = findUp.sync('package.json', { cwd: require.main!.filename });
|
261 | if (!packageJsonFile) throw new Exception('package.json is not found');
|
262 | export const logger = new Logger({ streams: [] });
|
263 |
|
264 | const extractPathRegex = /\s+at.*?\((.*?)\)/;
|
265 | const pathRegex = /^internal|(.*?\/node_modules\/(ts-node)\/)/;
|
266 | function cleanStackTrace(stack: string | undefined) {
|
267 | if (!stack) return;
|
268 | return stack
|
269 | .replace(/\\/g, '/')
|
270 | .split('\n')
|
271 | .filter(line => {
|
272 | const pathMatches = line.match(extractPathRegex);
|
273 | if (pathMatches === null) return true;
|
274 | const match = pathMatches[1];
|
275 | return !pathRegex.test(match);
|
276 | })
|
277 | .filter(line => line.trim() !== '')
|
278 | .join('\n');
|
279 | }
|
280 |
|
281 | export function setLoggerSettings(settings: LoggerSettings) {
|
282 | (logger as LoggerOpened).setSettings(settings);
|
283 | }
|
284 |
|
\ | No newline at end of file |