Skip to content

Commit

Permalink
Rotating file sink
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Apr 19, 2024
1 parent e57c43f commit f0e45b0
Show file tree
Hide file tree
Showing 7 changed files with 225 additions and 9 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
33 changes: 31 additions & 2 deletions logtape/filesink.deno.ts
Original file line number Diff line number Diff line change
@@ -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<Deno.FsFile> = {
export const denoDriver: RotatingFileSinkDriver<Deno.FsFile> = {
openSync(path: string) {
return Deno.openSync(path, { create: true, append: true });
},
Expand All @@ -22,6 +24,8 @@ export const denoDriver: FileSinkDriver<Deno.FsFile> = {
closeSync(fd) {
fd.close();
},
statSync: Deno.statSync,
renameSync: Deno.renameSync,
};

/**
Expand All @@ -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
33 changes: 31 additions & 2 deletions logtape/filesink.node.ts
Original file line number Diff line number Diff line change
@@ -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<number> = {
export const nodeDriver: RotatingFileSinkDriver<number> = {
openSync(path: string) {
return fs.openSync(path, "a");
},
writeSync: fs.writeSync,
flushSync: fs.fsyncSync,
closeSync: fs.closeSync,
statSync: fs.statSync,
renameSync: fs.renameSync,
};

/**
Expand All @@ -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
70 changes: 69 additions & 1 deletion logtape/filesink.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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
8 changes: 5 additions & 3 deletions logtape/filesink.web.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import type { FileSinkDriver } from "./sink.ts";
import type { RotatingFileSinkDriver } from "./sink.ts";

function notImplemented() {
function notImplemented<T>(): 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<void> = {
export const webDriver: RotatingFileSinkDriver<void> = {
openSync: notImplemented,
writeSync: notImplemented,
flushSync: notImplemented,
closeSync: notImplemented,
statSync: notImplemented,
renameSync: notImplemented,
};
2 changes: 1 addition & 1 deletion logtape/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions logtape/sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,89 @@ export function getFileSink<TFile>(
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<TFile> extends FileSinkDriver<TFile> {
/**
* 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<TFile>(
path: string,
options: RotatingFileSinkOptions & RotatingFileSinkDriver<TFile>,
): 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;
}

0 comments on commit f0e45b0

Please sign in to comment.