diff --git a/CHANGES.md b/CHANGES.md index dc85e3f..cf5212c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,8 @@ To be released. - The return type of `getStreamSink()` function became `Sink & AsyncDisposable` (was `Sink & Disposable`). + - Added `getRotatingFileSink()` function. + Version 0.1.0 ------------- diff --git a/logtape/filesink.deno.ts b/logtape/filesink.deno.ts index 677b10a..bd5ba42 100644 --- a/logtape/filesink.deno.ts +++ b/logtape/filesink.deno.ts @@ -1,15 +1,17 @@ import { webDriver } from "./filesink.web.ts"; import { - type FileSinkDriver, type FileSinkOptions, getFileSink as getBaseFileSink, + getRotatingFileSink as getBaseRotatingFileSink, + type RotatingFileSinkDriver, + type RotatingFileSinkOptions, type Sink, } from "./sink.ts"; /** * A Deno-specific file sink driver. */ -export const denoDriver: FileSinkDriver = { +export const denoDriver: RotatingFileSinkDriver = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, @@ -22,6 +24,8 @@ export const denoDriver: FileSinkDriver = { closeSync(fd) { fd.close(); }, + statSync: Deno.statSync, + renameSync: Deno.renameSync, }; /** @@ -44,4 +48,29 @@ export function getFileSink( return getBaseFileSink(path, { ...options, ...denoDriver }); } +/** + * Get a rotating file sink. + * + * This sink writes log records to a file, and rotates the file when it reaches + * the `maxSize`. The rotated files are named with the original file name + * followed by a dot and a number, starting from 1. The number is incremented + * for each rotation, and the maximum number of files to keep is `maxFiles`. + * + * Note that this function is unavailable in the browser. + * + * @param path A path to the file to write to. + * @param options The options for the sink and the file driver. + * @returns A sink that writes to the file. The sink is also a disposable + * object that closes the file when disposed. + */ +export function getRotatingFileSink( + path: string, + options: RotatingFileSinkOptions = {}, +): Sink & Disposable { + if ("document" in globalThis) { + return getBaseRotatingFileSink(path, { ...options, ...webDriver }); + } + return getBaseRotatingFileSink(path, { ...options, ...denoDriver }); +} + // cSpell: ignore filesink diff --git a/logtape/filesink.node.ts b/logtape/filesink.node.ts index 42b2be1..f2e78a3 100644 --- a/logtape/filesink.node.ts +++ b/logtape/filesink.node.ts @@ -1,22 +1,26 @@ import fs from "node:fs"; import { webDriver } from "./filesink.web.ts"; import { - type FileSinkDriver, type FileSinkOptions, getFileSink as getBaseFileSink, + getRotatingFileSink as getBaseRotatingFileSink, + type RotatingFileSinkDriver, + type RotatingFileSinkOptions, type Sink, } from "./sink.ts"; /** * A Node.js-specific file sink driver. */ -export const nodeDriver: FileSinkDriver = { +export const nodeDriver: RotatingFileSinkDriver = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, + statSync: fs.statSync, + renameSync: fs.renameSync, }; /** @@ -39,4 +43,29 @@ export function getFileSink( return getBaseFileSink(path, { ...options, ...nodeDriver }); } +/** + * Get a rotating file sink. + * + * This sink writes log records to a file, and rotates the file when it reaches + * the `maxSize`. The rotated files are named with the original file name + * followed by a dot and a number, starting from 1. The number is incremented + * for each rotation, and the maximum number of files to keep is `maxFiles`. + * + * Note that this function is unavailable in the browser. + * + * @param path A path to the file to write to. + * @param options The options for the sink and the file driver. + * @returns A sink that writes to the file. The sink is also a disposable + * object that closes the file when disposed. + */ +export function getRotatingFileSink( + path: string, + options: RotatingFileSinkOptions = {}, +): Sink & Disposable { + if ("document" in globalThis) { + return getBaseRotatingFileSink(path, { ...options, ...webDriver }); + } + return getBaseRotatingFileSink(path, { ...options, ...nodeDriver }); +} + // cSpell: ignore filesink diff --git a/logtape/filesink.test.ts b/logtape/filesink.test.ts index 6d2188a..0f7371e 100644 --- a/logtape/filesink.test.ts +++ b/logtape/filesink.test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "@std/assert/assert-equals"; -import { getFileSink } from "./filesink.deno.ts"; +import { getFileSink, getRotatingFileSink } from "./filesink.deno.ts"; import { debug, error, fatal, info, warning } from "./fixtures.ts"; import type { Sink } from "./sink.ts"; @@ -24,4 +24,72 @@ Deno.test("getFileSink()", () => { ); }); +Deno.test("getRotatingFileSink()", () => { + const path = Deno.makeTempFileSync(); + console.debug({ path }); + const sink: Sink & Disposable = getRotatingFileSink(path, { + maxSize: 150, + }); + sink(debug); + assertEquals( + Deno.readTextFileSync(path), + "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", + ); + sink(info); + assertEquals( + Deno.readTextFileSync(path), + `\ +2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! +`, + ); + sink(warning); + assertEquals( + Deno.readTextFileSync(path), + "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n", + ); + assertEquals( + Deno.readTextFileSync(`${path}.1`), + `\ +2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! +`, + ); + sink(error); + assertEquals( + Deno.readTextFileSync(path), + `\ +2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! +`, + ); + assertEquals( + Deno.readTextFileSync(`${path}.1`), + `\ +2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! +`, + ); + sink(fatal); + sink[Symbol.dispose](); + assertEquals( + Deno.readTextFileSync(path), + "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!\n", + ); + assertEquals( + Deno.readTextFileSync(`${path}.1`), + `\ +2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456! +`, + ); + assertEquals( + Deno.readTextFileSync(`${path}.2`), + `\ +2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456! +2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456! +`, + ); +}); + // cSpell: ignore filesink diff --git a/logtape/filesink.web.ts b/logtape/filesink.web.ts index f338c3e..e589f56 100644 --- a/logtape/filesink.web.ts +++ b/logtape/filesink.web.ts @@ -1,15 +1,17 @@ -import type { FileSinkDriver } from "./sink.ts"; +import type { RotatingFileSinkDriver } from "./sink.ts"; -function notImplemented() { +function notImplemented(): T { throw new Error("File sink is not available in the browser."); } /** * A browser-specific file sink driver. All methods throw an error. */ -export const webDriver: FileSinkDriver = { +export const webDriver: RotatingFileSinkDriver = { openSync: notImplemented, writeSync: notImplemented, flushSync: notImplemented, closeSync: notImplemented, + statSync: notImplemented, + renameSync: notImplemented, }; diff --git a/logtape/mod.ts b/logtape/mod.ts index 4d454f6..2312628 100644 --- a/logtape/mod.ts +++ b/logtape/mod.ts @@ -6,7 +6,7 @@ export { type LoggerConfig, reset, } from "./config.ts"; -export { getFileSink } from "./filesink.deno.ts"; +export { getFileSink, getRotatingFileSink } from "./filesink.deno.ts"; export { type Filter, type FilterLike, diff --git a/logtape/sink.ts b/logtape/sink.ts index 191e3c1..f4be3a1 100644 --- a/logtape/sink.ts +++ b/logtape/sink.ts @@ -171,3 +171,89 @@ export function getFileSink( sink[Symbol.dispose] = () => options.closeSync(fd); return sink; } + +/** + * Options for the {@link getRotatingFileSink} function. + */ +export interface RotatingFileSinkOptions extends FileSinkOptions { + /** + * The maximum bytes of the file before it is rotated. 1 MiB by default. + */ + maxSize?: number; + + /** + * The maximum number of files to keep. 5 by default. + */ + maxFiles?: number; +} + +/** + * A platform-specific rotating file sink driver. + */ +export interface RotatingFileSinkDriver extends FileSinkDriver { + /** + * Get the size of the file. + * @param path A path to the file. + * @returns The `size` of the file in bytes, in an object. + */ + statSync(path: string): { size: number }; + + /** + * Rename a file. + * @param oldPath A path to the file to rename. + * @param newPath A path to be renamed to. + */ + renameSync(oldPath: string, newPath: string): void; +} + +/** + * Get a platform-independent rotating file sink. + * + * This sink writes log records to a file, and rotates the file when it reaches + * the `maxSize`. The rotated files are named with the original file name + * followed by a dot and a number, starting from 1. The number is incremented + * for each rotation, and the maximum number of files to keep is `maxFiles`. + * + * @param path A path to the file to write to. + * @param options The options for the sink and the file driver. + * @returns A sink that writes to the file. The sink is also a disposable + * object that closes the file when disposed. + */ +export function getRotatingFileSink( + path: string, + options: RotatingFileSinkOptions & RotatingFileSinkDriver, +): Sink & Disposable { + const formatter = options.formatter ?? defaultTextFormatter; + const encoder = options.encoder ?? new TextEncoder(); + const maxSize = options.maxSize ?? 1024 * 1024; + const maxFiles = options.maxFiles ?? 5; + let { size: offset } = options.statSync(path); + let fd = options.openSync(path); + function shouldRollover(bytes: Uint8Array): boolean { + return offset + bytes.length > maxSize; + } + function performRollover(): void { + options.closeSync(fd); + for (let i = maxFiles - 1; i > 0; i--) { + const oldPath = `${path}.${i}`; + const newPath = `${path}.${i + 1}`; + try { + options.renameSync(oldPath, newPath); + } catch (_) { + // Continue if the file does not exist. + } + } + options.renameSync(path, `${path}.1`); + offset = 0; + fd = options.openSync(path); + } + const sink: Sink & Disposable = (record: LogRecord) => { + const bytes = encoder.encode(formatter(record)); + if (shouldRollover(bytes)) performRollover(); + options.writeSync(fd, bytes); + options.flushSync(fd); + offset += bytes.length; + }; + sink[Symbol.dispose] = () => options.closeSync(fd); + return sink; +}