UNPKG

8.41 kBPlain TextView Raw
1import mkdirp from 'mkdirp';
2import { createWriteStream, fstatSync, openSync, renameSync } from 'fs';
3import { dirname } from 'path';
4import words from './words';
5import colors from 'colors';
6import findUp from 'find-up';
7import SMTPTransport from 'nodemailer/lib/smtp-transport';
8import nodemailer from 'nodemailer';
9
10export 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}
17export class ClientException<T = object> extends BaseException<T> {}
18export class ExternalException<T = object> extends BaseException<T> {}
19export class Exception<T = object> extends BaseException<T> {}
20
21type Levels = keyof typeof levels;
22
23type 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 };
34export interface LoggerSettings {
35 streams: LoggerStreamConfig[];
36}
37
38export 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
111class LoggerOpened extends Logger {
112 setSettings(_settings: LoggerSettings) {}
113}
114
115abstract class LoggerStream {
116 constructor(public level: number) {}
117 abstract write(id: string, parentId: string, date: Date, type: Levels, name: string, json: object): void;
118}
119class 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}
162class 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}
202class 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
226function 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
251const levels = {
252 error: 0,
253 warn: 1,
254 external: 2,
255 info: 3,
256 clientError: 4,
257 trace: 5,
258};
259
260const packageJsonFile = findUp.sync('package.json', { cwd: require.main!.filename });
261if (!packageJsonFile) throw new Exception('package.json is not found');
262export const logger = new Logger({ streams: [] });
263
264const extractPathRegex = /\s+at.*?\((.*?)\)/;
265const pathRegex = /^internal|(.*?\/node_modules\/(ts-node)\/)/;
266function 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
281export function setLoggerSettings(settings: LoggerSettings) {
282 (logger as LoggerOpened).setSettings(settings);
283}
284
\No newline at end of file