0
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-03-03 17:34:47 -05:00

feat(std/log): improvements and new log handler (#4674)

This commit is contained in:
Chris Knight 2020-04-09 12:45:24 +01:00 committed by GitHub
parent 198a045dbe
commit 475a47cfb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 447 additions and 70 deletions

View file

@ -5,22 +5,23 @@
```ts ```ts
import * as log from "https://deno.land/std/log/mod.ts"; import * as log from "https://deno.land/std/log/mod.ts";
// simple default logger, you can customize it // Simple default logger out of the box. You can customize it
// by overriding logger and handler named "default" // by overriding logger and handler named "default", or providing
// additional logger configurations
log.debug("Hello world"); log.debug("Hello world");
log.info("Hello world"); log.info("Hello world");
log.warning("Hello world"); log.warning("Hello world");
log.error("Hello world"); log.error("Hello world");
log.critical("500 Internal server error"); log.critical("500 Internal server error");
// custom configuration // custom configuration with 2 loggers (the default and `tasks` loggers)
await log.setup({ await log.setup({
handlers: { handlers: {
console: new log.handlers.ConsoleHandler("DEBUG"), console: new log.handlers.ConsoleHandler("DEBUG"),
file: new log.handlers.FileHandler("WARNING", { file: new log.handlers.FileHandler("WARNING", {
filename: "./log.txt", filename: "./log.txt",
// you can change format of output message // you can change format of output message using any keys in `LogRecord`
formatter: "{levelName} {msg}", formatter: "{levelName} {msg}",
}), }),
}, },
@ -48,7 +49,7 @@ logger.warning("buzz"); // logs to both `console` and `file` handlers
// get custom logger // get custom logger
logger = log.getLogger("tasks"); logger = log.getLogger("tasks");
logger.debug("fizz"); // won't get output becase this logger has "ERROR" level logger.debug("fizz"); // won't get output because this logger has "ERROR" level
logger.error("buzz"); // log to `console` logger.error("buzz"); // log to `console`
// if you try to use a logger that hasn't been configured // if you try to use a logger that hasn't been configured
@ -62,9 +63,9 @@ unknownLogger.info("foobar"); // no-op
### Loggers ### Loggers
Loggers are objects that you interact with. When you use logger method it Loggers are objects that you interact with. When you use a logger method it
constructs a `LogRecord` and passes it down to its handlers for output. To constructs a `LogRecord` and passes it down to its handlers for output. To
create custom loggers speficify them in `loggers` when calling `log.setup`. create custom loggers, specify them in `loggers` when calling `log.setup`.
#### `LogRecord` #### `LogRecord`
@ -72,28 +73,107 @@ create custom loggers speficify them in `loggers` when calling `log.setup`.
well some meta data that can be later used when formatting a message. well some meta data that can be later used when formatting a message.
```ts ```ts
interface LogRecord { class LogRecord {
msg: string; readonly msg: string;
args: any[]; readonly args: any[];
datetime: Date; readonly datetime: Date;
level: number; readonly level: number;
levelName: string; readonly levelName: string;
} }
``` ```
### Handlers ### Handlers
Handlers are responsible for actual output of log messages. When handler is Handlers are responsible for actual output of log messages. When a handler is
called by logger it firstly checks that `LogRecord`'s level is not lower than called by a logger, it firstly checks that `LogRecord`'s level is not lower than
level of the handler. If level check passes, handlers formats log record into level of the handler. If level check passes, handlers formats log record into
string and outputs it to target. string and outputs it to target.
`log` module comes with two built-in handlers: `log` module comes with three built-in handlers:
- `ConsoleHandler` - (default) #### `ConsoleHandler`
- `FileHandler`
#### Custom message format This is the default logger. It will output color coded log messages to the
console via `console.log()`. This logger takes `HandlerOptions`:
```typescript
type FormatterFunction = (logRecord: LogRecord) => string;
interface HandlerOptions {
formatter?: string | FormatterFunction; //see `Custom message format` below
}
```
#### `FileHandler`
This handler will output to a file using an optional mode (default is `a`, e.g.
append). The file will grow indefinitely. This logger takes `FileOptions`:
```typescript
interface FileHandlerOptions {
formatter?: string | FormatterFunction; //see `Custom message format` below
filename: string;
mode?: LogMode; // 'a', 'w', 'x'
}
```
Behavior of the log modes is as follows:
- `'a'` - Default mode. Appends new log messages to the end of an existing log
file, or create a new log file if none exists
- `'w'` - Upon creation of the handler, any existing log file will be removed
and a new one created.
- `'x'` - This will create a new log file and throw an error if one already
exists
This handler requires `--allow-write` permission on the log file.
#### `RotatingFileHandler`
This handler extends the functionality of the `FileHandler` by "rotating" the
log file when it reaches a certain size. `maxBytes` specifies the maximum size
in bytes that the log file can grow to before rolling over to a new one. If the
size of the new log message plus the current log file size exceeds `maxBytes`
then a roll over is triggered. When a roll over occurs, before the log message
is written, the log file is renamed and appended with `.1`. If a `.1` version
already existed, it would have been renamed `.2` first and so on. The maximum
number of log files to keep is specified by `maxBackupCount`. After the renames
are complete the log message is written to the original, now blank, file.
Example: Given `log.txt`, `log.txt.1`, `log.txt.2` and `log.txt.3`, a
`maxBackupCount` of 3 and a new log message which would cause `log.txt` to
exceed `maxBytes`, then `log.txt.2` would be renamed to `log.txt.3` (thereby
discarding the original contents of `log.txt.3` since 3 is the maximum number of
backups to keep), `log.txt.1` would be renamed to `log.txt.2`, `log.txt` would
be renamed to `log.txt.1` and finally `log.txt` would be created from scratch
where the new log message would be written.
Options for this handler are:
```typescript
interface RotatingFileHandlerOptions {
maxBytes: number;
maxBackupCount: number;
formatter?: string | FormatterFunction; //see `Custom message format` below
filename: string;
mode?: LogMode; // 'a', 'w', 'x'
}
```
Additional notes on `mode` as described above:
- `'a'` Default mode. As above, this will pick up where the logs left off in
rotation, or create a new log file if it doesn't exist.
- `'w'` in addition to starting with a clean `filename`, this mode will also
cause any existing backups (up to `maxBackupCount`) to be deleted on setup
giving a fully clean slate.
- `'x'` requires that neither `filename`, nor any backups (up to
`maxBackupCount`), exist before setup
This handler requires both `--allow-read` and `--allow-write` permissions on the
log files.
### Custom message format
If you want to override default format of message you can define `formatter` If you want to override default format of message you can define `formatter`
option for handler. It can be either simple string-based format that uses option for handler. It can be either simple string-based format that uses

View file

@ -1,13 +1,15 @@
// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
const { open } = Deno; const { open, openSync, close, renameSync, statSync } = Deno;
type File = Deno.File; type File = Deno.File;
type Writer = Deno.Writer; type Writer = Deno.Writer;
import { getLevelByName, LogLevel } from "./levels.ts"; import { getLevelByName, LogLevel } from "./levels.ts";
import { LogRecord } from "./logger.ts"; import { LogRecord } from "./logger.ts";
import { red, yellow, blue, bold } from "../fmt/colors.ts"; import { red, yellow, blue, bold } from "../fmt/colors.ts";
import { existsSync, exists } from "../fs/exists.ts";
const DEFAULT_FORMATTER = "{levelName} {msg}"; const DEFAULT_FORMATTER = "{levelName} {msg}";
type FormatterFunction = (logRecord: LogRecord) => string; type FormatterFunction = (logRecord: LogRecord) => string;
type LogMode = "a" | "w" | "x";
interface HandlerOptions { interface HandlerOptions {
formatter?: string | FormatterFunction; formatter?: string | FormatterFunction;
@ -85,33 +87,113 @@ export class ConsoleHandler extends BaseHandler {
export abstract class WriterHandler extends BaseHandler { export abstract class WriterHandler extends BaseHandler {
protected _writer!: Writer; protected _writer!: Writer;
private _encoder = new TextEncoder(); #encoder = new TextEncoder();
log(msg: string): void { abstract log(msg: string): void;
this._writer.write(this._encoder.encode(msg + "\n"));
}
} }
interface FileHandlerOptions extends HandlerOptions { interface FileHandlerOptions extends HandlerOptions {
filename: string; filename: string;
mode?: LogMode;
} }
export class FileHandler extends WriterHandler { export class FileHandler extends WriterHandler {
private _file!: File; protected _file!: File;
private _filename: string; protected _filename: string;
protected _mode: LogMode;
#encoder = new TextEncoder();
constructor(levelName: string, options: FileHandlerOptions) { constructor(levelName: string, options: FileHandlerOptions) {
super(levelName, options); super(levelName, options);
this._filename = options.filename; this._filename = options.filename;
// default to append mode, write only
this._mode = options.mode ? options.mode : "a";
} }
async setup(): Promise<void> { async setup(): Promise<void> {
// open file in append mode - write only this._file = await open(this._filename, this._mode);
this._file = await open(this._filename, "a");
this._writer = this._file; this._writer = this._file;
} }
log(msg: string): void {
Deno.writeSync(this._file.rid, this.#encoder.encode(msg + "\n"));
}
async destroy(): Promise<void> { async destroy(): Promise<void> {
await this._file.close(); await this._file.close();
} }
} }
interface RotatingFileHandlerOptions extends FileHandlerOptions {
maxBytes: number;
maxBackupCount: number;
}
export class RotatingFileHandler extends FileHandler {
#maxBytes: number;
#maxBackupCount: number;
constructor(levelName: string, options: RotatingFileHandlerOptions) {
super(levelName, options);
this.#maxBytes = options.maxBytes;
this.#maxBackupCount = options.maxBackupCount;
}
async setup(): Promise<void> {
if (this.#maxBytes < 1) {
throw new Error("maxBytes cannot be less than 1");
}
if (this.#maxBackupCount < 1) {
throw new Error("maxBackupCount cannot be less than 1");
}
await super.setup();
if (this._mode === "w") {
// Remove old backups too as it doesn't make sense to start with a clean
// log file, but old backups
for (let i = 1; i <= this.#maxBackupCount; i++) {
if (await exists(this._filename + "." + i)) {
await Deno.remove(this._filename + "." + i);
}
}
} else if (this._mode === "x") {
// Throw if any backups also exist
for (let i = 1; i <= this.#maxBackupCount; i++) {
if (await exists(this._filename + "." + i)) {
Deno.close(this._file.rid);
throw new Deno.errors.AlreadyExists(
"Backup log file " + this._filename + "." + i + " already exists"
);
}
}
}
}
handle(logRecord: LogRecord): void {
if (this.level > logRecord.level) return;
const msg = this.format(logRecord);
const currentFileSize = statSync(this._filename).size;
if (currentFileSize + msg.length > this.#maxBytes) {
this.rotateLogFiles();
}
return this.log(msg);
}
rotateLogFiles(): void {
close(this._file.rid);
for (let i = this.#maxBackupCount - 1; i >= 0; i--) {
const source = this._filename + (i === 0 ? "" : "." + i);
const dest = this._filename + "." + (i + 1);
if (existsSync(source)) {
renameSync(source, dest);
}
}
this._file = openSync(this._filename, this._mode);
this._writer = this._file;
}
}

View file

@ -1,8 +1,12 @@
// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
const { test } = Deno; const { test } = Deno;
import { assertEquals } from "../testing/asserts.ts"; import { assert, assertEquals, assertThrowsAsync } from "../testing/asserts.ts";
import { LogLevel, getLevelName, getLevelByName } from "./levels.ts"; import { LogLevel, getLevelName, getLevelByName } from "./levels.ts";
import { BaseHandler } from "./handlers.ts"; import { BaseHandler, FileHandler, RotatingFileHandler } from "./handlers.ts";
import { LogRecord } from "./logger.ts";
import { existsSync } from "../fs/exists.ts";
const LOG_FILE = "./test_log.file";
class TestHandler extends BaseHandler { class TestHandler extends BaseHandler {
public messages: string[] = []; public messages: string[] = [];
@ -47,13 +51,9 @@ test(function simpleHandler(): void {
for (const levelName in LogLevel) { for (const levelName in LogLevel) {
const level = getLevelByName(levelName); const level = getLevelByName(levelName);
handler.handle({ handler.handle(
msg: `${levelName.toLowerCase()}-test`, new LogRecord(`${levelName.toLowerCase()}-test`, [], level)
args: [], );
datetime: new Date(),
level: level,
levelName: levelName,
});
} }
assertEquals(handler.level, testCase); assertEquals(handler.level, testCase);
@ -67,13 +67,7 @@ test(function testFormatterAsString(): void {
formatter: "test {levelName} {msg}", formatter: "test {levelName} {msg}",
}); });
handler.handle({ handler.handle(new LogRecord("Hello, world!", [], LogLevel.DEBUG));
msg: "Hello, world!",
args: [],
datetime: new Date(),
level: LogLevel.DEBUG,
levelName: "DEBUG",
});
assertEquals(handler.messages, ["test DEBUG Hello, world!"]); assertEquals(handler.messages, ["test DEBUG Hello, world!"]);
}); });
@ -81,16 +75,229 @@ test(function testFormatterAsString(): void {
test(function testFormatterAsFunction(): void { test(function testFormatterAsFunction(): void {
const handler = new TestHandler("DEBUG", { const handler = new TestHandler("DEBUG", {
formatter: (logRecord): string => formatter: (logRecord): string =>
`fn formmatter ${logRecord.levelName} ${logRecord.msg}`, `fn formatter ${logRecord.levelName} ${logRecord.msg}`,
}); });
handler.handle({ handler.handle(new LogRecord("Hello, world!", [], LogLevel.ERROR));
msg: "Hello, world!",
args: [],
datetime: new Date(),
level: LogLevel.ERROR,
levelName: "ERROR",
});
assertEquals(handler.messages, ["fn formmatter ERROR Hello, world!"]); assertEquals(handler.messages, ["fn formatter ERROR Hello, world!"]);
});
test({
name: "FileHandler with mode 'w' will wipe clean existing log file",
async fn() {
const fileHandler = new FileHandler("WARNING", {
filename: LOG_FILE,
mode: "w",
});
await fileHandler.setup();
fileHandler.handle(new LogRecord("Hello World", [], LogLevel.WARNING));
await fileHandler.destroy();
const firstFileSize = (await Deno.stat(LOG_FILE)).size;
await fileHandler.setup();
fileHandler.handle(new LogRecord("Hello World", [], LogLevel.WARNING));
await fileHandler.destroy();
const secondFileSize = (await Deno.stat(LOG_FILE)).size;
assertEquals(secondFileSize, firstFileSize);
Deno.removeSync(LOG_FILE);
},
});
test({
name: "FileHandler with mode 'x' will throw if log file already exists",
async fn() {
await assertThrowsAsync(
async () => {
Deno.writeFileSync(LOG_FILE, new TextEncoder().encode("hello world"));
const fileHandler = new FileHandler("WARNING", {
filename: LOG_FILE,
mode: "x",
});
await fileHandler.setup();
},
Deno.errors.AlreadyExists,
"ile exists"
);
Deno.removeSync(LOG_FILE);
},
});
test({
name:
"RotatingFileHandler with mode 'w' will wipe clean existing log file and remove others",
async fn() {
Deno.writeFileSync(LOG_FILE, new TextEncoder().encode("hello world"));
Deno.writeFileSync(
LOG_FILE + ".1",
new TextEncoder().encode("hello world")
);
Deno.writeFileSync(
LOG_FILE + ".2",
new TextEncoder().encode("hello world")
);
Deno.writeFileSync(
LOG_FILE + ".3",
new TextEncoder().encode("hello world")
);
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 50,
maxBackupCount: 3,
mode: "w",
});
await fileHandler.setup();
await fileHandler.destroy();
assertEquals((await Deno.stat(LOG_FILE)).size, 0);
assert(!existsSync(LOG_FILE + ".1"));
assert(!existsSync(LOG_FILE + ".2"));
assert(!existsSync(LOG_FILE + ".3"));
Deno.removeSync(LOG_FILE);
},
});
test({
name:
"RotatingFileHandler with mode 'x' will throw if any log file already exists",
async fn() {
await assertThrowsAsync(
async () => {
Deno.writeFileSync(
LOG_FILE + ".3",
new TextEncoder().encode("hello world")
);
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 50,
maxBackupCount: 3,
mode: "x",
});
await fileHandler.setup();
},
Deno.errors.AlreadyExists,
"Backup log file " + LOG_FILE + ".3 already exists"
);
Deno.removeSync(LOG_FILE + ".3");
Deno.removeSync(LOG_FILE);
},
});
test({
name: "RotatingFileHandler with first rollover",
async fn() {
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 25,
maxBackupCount: 3,
mode: "w",
});
await fileHandler.setup();
fileHandler.handle(new LogRecord("AAA", [], LogLevel.ERROR)); // 'ERROR AAA\n' = 10 bytes
assertEquals((await Deno.stat(LOG_FILE)).size, 10);
fileHandler.handle(new LogRecord("AAA", [], LogLevel.ERROR));
assertEquals((await Deno.stat(LOG_FILE)).size, 20);
fileHandler.handle(new LogRecord("AAA", [], LogLevel.ERROR));
// Rollover occurred. Log file now has 1 record, rollover file has the original 2
assertEquals((await Deno.stat(LOG_FILE)).size, 10);
assertEquals((await Deno.stat(LOG_FILE + ".1")).size, 20);
await fileHandler.destroy();
Deno.removeSync(LOG_FILE);
Deno.removeSync(LOG_FILE + ".1");
},
});
test({
name: "RotatingFileHandler with all backups rollover",
async fn() {
Deno.writeFileSync(LOG_FILE, new TextEncoder().encode("original log file"));
Deno.writeFileSync(
LOG_FILE + ".1",
new TextEncoder().encode("original log.1 file")
);
Deno.writeFileSync(
LOG_FILE + ".2",
new TextEncoder().encode("original log.2 file")
);
Deno.writeFileSync(
LOG_FILE + ".3",
new TextEncoder().encode("original log.3 file")
);
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 2,
maxBackupCount: 3,
mode: "a",
});
await fileHandler.setup();
fileHandler.handle(new LogRecord("AAA", [], LogLevel.ERROR)); // 'ERROR AAA\n' = 10 bytes
await fileHandler.destroy();
const decoder = new TextDecoder();
assertEquals(decoder.decode(Deno.readFileSync(LOG_FILE)), "ERROR AAA\n");
assertEquals(
decoder.decode(Deno.readFileSync(LOG_FILE + ".1")),
"original log file"
);
assertEquals(
decoder.decode(Deno.readFileSync(LOG_FILE + ".2")),
"original log.1 file"
);
assertEquals(
decoder.decode(Deno.readFileSync(LOG_FILE + ".3")),
"original log.2 file"
);
assert(!existsSync(LOG_FILE + ".4"));
Deno.removeSync(LOG_FILE);
Deno.removeSync(LOG_FILE + ".1");
Deno.removeSync(LOG_FILE + ".2");
Deno.removeSync(LOG_FILE + ".3");
},
});
test({
name: "RotatingFileHandler maxBytes cannot be less than 1",
async fn() {
await assertThrowsAsync(
async () => {
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 0,
maxBackupCount: 3,
mode: "w",
});
await fileHandler.setup();
},
Error,
"maxBytes cannot be less than 1"
);
},
});
test({
name: "RotatingFileHandler maxBackupCount cannot be less than 1",
async fn() {
await assertThrowsAsync(
async () => {
const fileHandler = new RotatingFileHandler("WARNING", {
filename: LOG_FILE,
maxBytes: 50,
maxBackupCount: 0,
mode: "w",
});
await fileHandler.setup();
},
Error,
"maxBackupCount cannot be less than 1"
);
},
}); });

View file

@ -2,12 +2,26 @@
import { LogLevel, getLevelByName, getLevelName } from "./levels.ts"; import { LogLevel, getLevelByName, getLevelName } from "./levels.ts";
import { BaseHandler } from "./handlers.ts"; import { BaseHandler } from "./handlers.ts";
export interface LogRecord { export class LogRecord {
msg: string; readonly msg: string;
args: unknown[]; #args: unknown[];
datetime: Date; #datetime: Date;
level: number; readonly level: number;
levelName: string; readonly levelName: string;
constructor(msg: string, args: unknown[], level: number) {
this.msg = msg;
this.#args = [...args];
this.level = level;
this.#datetime = new Date();
this.levelName = getLevelName(level);
}
get args(): unknown[] {
return [...this.#args];
}
get datetime(): Date {
return new Date(this.#datetime.getTime());
}
} }
export class Logger { export class Logger {
@ -26,16 +40,8 @@ export class Logger {
_log(level: number, msg: string, ...args: unknown[]): void { _log(level: number, msg: string, ...args: unknown[]): void {
if (this.level > level) return; if (this.level > level) return;
// TODO: it'd be a good idea to make it immutable, so const record: LogRecord = new LogRecord(msg, args, level);
// no handler mangles it by mistake
// TODO: iterpolate msg with values
const record: LogRecord = {
msg: msg,
args: args,
datetime: new Date(),
level: level,
levelName: getLevelName(level),
};
this.handlers.forEach((handler): void => { this.handlers.forEach((handler): void => {
handler.handle(record); handler.handle(record);
}); });

View file

@ -10,7 +10,7 @@ class TestHandler extends BaseHandler {
public records: LogRecord[] = []; public records: LogRecord[] = [];
handle(record: LogRecord): void { handle(record: LogRecord): void {
this.records.push({ ...record }); this.records.push(record);
super.handle(record); super.handle(record);
} }

View file

@ -5,6 +5,7 @@ import {
ConsoleHandler, ConsoleHandler,
WriterHandler, WriterHandler,
FileHandler, FileHandler,
RotatingFileHandler,
} from "./handlers.ts"; } from "./handlers.ts";
import { assert } from "../testing/asserts.ts"; import { assert } from "../testing/asserts.ts";
@ -47,6 +48,7 @@ export const handlers = {
ConsoleHandler, ConsoleHandler,
WriterHandler, WriterHandler,
FileHandler, FileHandler,
RotatingFileHandler,
}; };
export function getLogger(name?: string): Logger { export function getLogger(name?: string): Logger {