UNPKG

@logtape/file

Version:

File sink and rotating file sink for LogTape

695 lines (637 loc) 21.4 kB
import { getFileSink, getRotatingFileSink } from "#filesink"; import { suite } from "@alinea/suite"; import { isDeno } from "@david/which-runtime"; import type { Sink } from "@logtape/logtape"; import { assertEquals } from "@std/assert/equals"; import { assertThrows } from "@std/assert/throws"; import { join } from "@std/path/join"; import fs from "node:fs"; import { tmpdir } from "node:os"; import { debug, error, fatal, info, warning } from "../logtape/fixtures.ts"; import { type FileSinkDriver, getBaseFileSink } from "./filesink.base.ts"; const test = suite(import.meta); function makeTempFileSync(): string { return join(fs.mkdtempSync(join(tmpdir(), "logtape-")), "logtape.txt"); } test("getBaseFileSink()", () => { const path = makeTempFileSync(); let sink: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink = getBaseFileSink(path, driver); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink = getBaseFileSink(path, driver); } sink(debug); sink(info); sink(warning); sink(error); sink(fatal); sink[Symbol.dispose](); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! 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! 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! `, ); }); test("getBaseFileSink() with lazy option", () => { const pathDir = fs.mkdtempSync(join(tmpdir(), "logtape-")); const path = join(pathDir, "test.log"); let sink: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink = getBaseFileSink(path, { ...driver, lazy: true }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink = getBaseFileSink(path, { ...driver, lazy: true }); } if (isDeno) { assertThrows( () => Deno.lstatSync(path), Deno.errors.NotFound, ); } else { assertEquals(fs.existsSync(path), false); } sink(debug); sink(info); sink(warning); sink(error); sink(fatal); sink[Symbol.dispose](); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! 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! 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! `, ); }); test("getFileSink()", () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getFileSink(path); sink(debug); sink(info); sink(warning); sink(error); sink(fatal); sink[Symbol.dispose](); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! 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! 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! `, ); }); test("getFileSink() with bufferSize: 0 (no buffering)", () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getFileSink(path, { bufferSize: 0 }); // Write first log entry sink(debug); // With no buffering, content should be immediately written to file assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); // Write second log entry sink(info); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Write remaining entries sink(warning); sink(error); sink(fatal); sink[Symbol.dispose](); // Final verification assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! 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! 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456! `, ); }); test("getFileSink() with small buffer size", () => { const path = makeTempFileSync(); // Use a small buffer size (100 characters) to test buffering behavior const sink: Sink & Disposable = getFileSink(path, { bufferSize: 100 }); // Write first log entry (about 65 characters) sink(debug); // Should be buffered, not yet written to file assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), ""); // Write second log entry - this should exceed buffer size and trigger flush sink(info); // Both entries should now be written to file assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Write third log entry - should be buffered again sink(warning); // Should still only have the first two entries assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Dispose should flush remaining buffer content sink[Symbol.dispose](); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! `, ); }); test("getRotatingFileSink() with bufferSize: 0 (no buffering)", () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getRotatingFileSink(path, { maxSize: 150, bufferSize: 0, // No buffering - immediate writes }); // Write first log entry - should be immediately written sink(debug); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); // Write second log entry - should be immediately written sink(info); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Write third log entry - should trigger rotation sink(warning); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n", ); // Check that rotation occurred assertEquals( fs.readFileSync(`${path}.1`, { encoding: "utf-8" }), `\ 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[Symbol.dispose](); }); test("getRotatingFileSink() with small buffer size", () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getRotatingFileSink(path, { maxSize: 200, // Larger maxSize to allow for buffering tests bufferSize: 100, // Small buffer to test interaction with rotation }); // Write first log entry - should be buffered sink(debug); assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), ""); // Write second log entry - should trigger buffer flush sink(info); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Write third log entry - should be buffered again sink(warning); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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! `, ); // Write fourth log entry - should flush buffer and likely trigger rotation sink(error); // Dispose should flush any remaining buffer content sink[Symbol.dispose](); // Verify final state - all entries should be written somewhere const mainContent = fs.readFileSync(path, { encoding: "utf-8" }); let rotatedContent = ""; try { rotatedContent = fs.readFileSync(`${path}.1`, { encoding: "utf-8" }); } catch { // No rotation occurred } const allContent = mainContent + rotatedContent; // All four log entries should be present exactly once in either main or rotated file const expectedEntries = [ "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!\n", "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n", "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!\n", ]; for (const entry of expectedEntries) { assertEquals( allContent.includes(entry), true, `Missing log entry: ${entry.trim()}`, ); } // Verify each entry appears exactly once for (const entry of expectedEntries) { const firstIndex = allContent.indexOf(entry); const lastIndex = allContent.lastIndexOf(entry); assertEquals(firstIndex, lastIndex, `Duplicate log entry: ${entry.trim()}`); } }); test("getRotatingFileSink()", () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getRotatingFileSink(path, { maxSize: 150, bufferSize: 0, // Disable buffering for this test to maintain existing behavior }); sink(debug); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); sink(info); assertEquals( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!\n", ); assertEquals( fs.readFileSync(`${path}.1`, { encoding: "utf-8" }), `\ 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( fs.readFileSync(path, { encoding: "utf-8" }), `\ 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( fs.readFileSync(`${path}.1`, { encoding: "utf-8" }), `\ 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( fs.readFileSync(path, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!\n", ); assertEquals( fs.readFileSync(`${path}.1`, { encoding: "utf-8" }), `\ 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( fs.readFileSync(`${path}.2`, { encoding: "utf-8" }), `\ 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! `, ); const dirPath = fs.mkdtempSync(join(tmpdir(), "logtape-")); const path2 = join(dirPath, "log"); const sink2: Sink & Disposable = getRotatingFileSink(path2, { maxSize: 150, bufferSize: 0, // Disable buffering for this test to maintain existing behavior }); sink2(debug); assertEquals( fs.readFileSync(path2, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); sink2[Symbol.dispose](); }); test("getBaseFileSink() with buffer edge cases", () => { // Test negative bufferSize (should behave like bufferSize: 0) const path1 = makeTempFileSync(); let sink1: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink1 = getBaseFileSink(path1, { ...driver, bufferSize: -10 }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink1 = getBaseFileSink(path1, { ...driver, bufferSize: -10 }); } sink1(debug); // With negative bufferSize, should write immediately assertEquals( fs.readFileSync(path1, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); sink1[Symbol.dispose](); // Test bufferSize of 1 (very small) const path2 = makeTempFileSync(); let sink2: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink2 = getBaseFileSink(path2, { ...driver, bufferSize: 1 }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink2 = getBaseFileSink(path2, { ...driver, bufferSize: 1 }); } sink2(debug); // With bufferSize of 1, should write immediately since log entry > 1 char assertEquals( fs.readFileSync(path2, { encoding: "utf-8" }), "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!\n", ); sink2[Symbol.dispose](); // Test very large bufferSize const path3 = makeTempFileSync(); let sink3: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink3 = getBaseFileSink(path3, { ...driver, bufferSize: 10000 }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink3 = getBaseFileSink(path3, { ...driver, bufferSize: 10000 }); } // Write multiple entries that shouldn't exceed the large buffer sink3(debug); sink3(info); sink3(warning); // Should still be buffered (file empty) assertEquals(fs.readFileSync(path3, { encoding: "utf-8" }), ""); // Dispose should flush all buffered content sink3[Symbol.dispose](); assertEquals( fs.readFileSync(path3, { encoding: "utf-8" }), `\ 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! 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456! `, ); }); test("getBaseFileSink() with time-based flushing", async () => { const path = makeTempFileSync(); let sink: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink = getBaseFileSink(path, { ...driver, bufferSize: 1000, // Large buffer to prevent size-based flushing flushInterval: 100, // 100ms flush interval for testing }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink = getBaseFileSink(path, { ...driver, bufferSize: 1000, // Large buffer to prevent size-based flushing flushInterval: 100, // 100ms flush interval for testing }); } // Create a log record with current timestamp const record1 = { ...debug, timestamp: Date.now() }; sink(record1); // Should be buffered (file empty initially) assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), ""); // Wait for flush interval to pass and write another record await new Promise((resolve) => setTimeout(resolve, 150)); const record2 = { ...info, timestamp: Date.now() }; sink(record2); // First record should now be flushed due to time interval const content = fs.readFileSync(path, { encoding: "utf-8" }); assertEquals(content.includes("Hello, 123 & 456!"), true); sink[Symbol.dispose](); }); test("getRotatingFileSink() with time-based flushing", async () => { const path = makeTempFileSync(); const sink: Sink & Disposable = getRotatingFileSink(path, { maxSize: 1024 * 1024, // Large maxSize to prevent rotation bufferSize: 1000, // Large buffer to prevent size-based flushing flushInterval: 100, // 100ms flush interval for testing }); // Create a log record with current timestamp const record1 = { ...debug, timestamp: Date.now() }; sink(record1); // Should be buffered (file empty initially) assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), ""); // Wait for flush interval to pass and write another record await new Promise((resolve) => setTimeout(resolve, 150)); const record2 = { ...info, timestamp: Date.now() }; sink(record2); // First record should now be flushed due to time interval const content = fs.readFileSync(path, { encoding: "utf-8" }); assertEquals(content.includes("Hello, 123 & 456!"), true); sink[Symbol.dispose](); }); test("getBaseFileSink() with flushInterval disabled", () => { const path = makeTempFileSync(); let sink: Sink & Disposable; if (isDeno) { const driver: FileSinkDriver<Deno.FsFile> = { openSync(path: string) { return Deno.openSync(path, { create: true, append: true }); }, writeSync(fd, chunk) { fd.writeSync(chunk); }, flushSync(fd) { fd.syncSync(); }, closeSync(fd) { fd.close(); }, }; sink = getBaseFileSink(path, { ...driver, bufferSize: 1000, // Large buffer to prevent size-based flushing flushInterval: 0, // Disable time-based flushing }); } else { const driver: FileSinkDriver<number> = { openSync(path: string) { return fs.openSync(path, "a"); }, writeSync: fs.writeSync, flushSync: fs.fsyncSync, closeSync: fs.closeSync, }; sink = getBaseFileSink(path, { ...driver, bufferSize: 1000, // Large buffer to prevent size-based flushing flushInterval: 0, // Disable time-based flushing }); } // Create log records with simulated time gap const now = Date.now(); const record1 = { ...debug, timestamp: now }; const record2 = { ...info, timestamp: now + 10000 }; // 10 seconds later sink(record1); sink(record2); // Should still be buffered since time-based flushing is disabled assertEquals(fs.readFileSync(path, { encoding: "utf-8" }), ""); // Only disposal should flush sink[Symbol.dispose](); const content = fs.readFileSync(path, { encoding: "utf-8" }); assertEquals(content.includes("Hello, 123 & 456!"), true); }); // cSpell: ignore filesink