diff --git a/CHANGES.md b/CHANGES.md index 884a3f3..311a4c9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,17 @@ Version 0.2.0 To be released. + - Sinks now can be asynchronously disposed of. This is useful for + sinks that need to flush their buffers before being closed. + + - Added `dispose()` function. + - The return type of `configure()` function became `Promise` + (was `void`). + - The return type of `reset()` function became `Promise` + (was `void`). + - Configured sinks that implement `AsyncDisposable` are now disposed + of asynchronously when the configuration is reset or the program exits. + Version 0.1.0 ------------- diff --git a/README.md b/README.md index 84c6301..a07c641 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ the application to set up LogTape): ~~~~ typescript import { configure, getConsoleSink } from "@logtape/logtape"; -configure({ +await configure({ sinks: { console: getConsoleSink() }, filters: {}, loggers: [ @@ -140,7 +140,7 @@ Here's an example of setting log levels for different categories: ~~~~ typescript import { configure, getConsoleSink } from "@logtape/logtape"; -configure({ +await configure({ sinks: { console: getConsoleSink(), }, @@ -169,7 +169,7 @@ Here's a simple example of a sink that writes log messages to console: ~~~~ typescript import { configure } from "@logtape/logtape"; -configure({ +await configure({ sinks: { console(record) { console.log(record.message); @@ -187,7 +187,7 @@ provides a console sink: ~~~~ typescript import { configure, getConsoleSink } from "@logtape/logtape"; -configure({ +await configure({ sinks: { console: getConsoleSink(), }, @@ -209,7 +209,7 @@ messages to the standard error: ~~~~ typescript // Deno: -configure({ +await configure({ sinks: { stream: getStreamSink(Deno.stderr.writable), }, @@ -221,7 +221,7 @@ configure({ // Node.js: import stream from "node:stream"; -configure({ +await configure({ sinks: { stream: getStreamSink(stream.Writable.toWeb(process.stderr)), }, @@ -256,7 +256,7 @@ writes log messages to a file: ~~~~ typescript import { getFileSink } from "@logtape/logtape"; -configure({ +await configure({ sinks: { file: getFileSink("my-app.log"), }, @@ -283,7 +283,7 @@ export type TextFormatter = (record: LogRecord) => string; Here's an example of a text formatter that writes log messages in a JSON format: ~~~~ typescript -configure({ +await configure({ sinks: { stream: getStreamSink(Deno.stderr.writable, { formatter: JSON.stringify, @@ -309,6 +309,40 @@ disposableSink[Symbol.dispose] = () => { }; ~~~~ +A sync can be asynchronously disposed of as well. The type of an asynchronous +disposable sink is: `Sink & AsyncDisposable`. You can create an asynchronous +disposable sink by defining a `[Symbol.asyncDispose]` method: + +~~~~ typescript +const asyncDisposableSink: Sink & AsyncDisposable = (record: LogRecord) => { + console.log(record.message); +}; +asyncDisposableSink[Symbol.asyncDispose] = async () => { + console.log("Disposed!"); +}; +~~~~ + +### Explicit disposal + +You can explicitly dispose of a sink by calling the `dispose()` method. It is +useful when you want to flush the buffer of a sink without blocking returning +a response in edge functions. Here's an example of using the `dispose()` +with [`ctx.waitUntil()`] in Cloudflare Workers: + +~~~~ typescript +import { configure, dispose } from "@logtape/logtape"; + +export default { + async fetch(request, env, ctx) { + await configure({ /* ... */ }); + // ... + ctx.waitUntil(dispose()); + } +} +~~~~ + +[`ctx.waitUntil()`]: https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil + Testing ------- @@ -326,16 +360,16 @@ the following code shows how to reset the configuration after a test import { configure, reset } from "@logtape/logtape"; Deno.test("my test", async (t) => { - await t.step("set up", () => { - configure({ /* ... */ }); + await t.step("set up", async () => { + await configure({ /* ... */ }); }); await t.step("run test", () => { // Run the test }); - await t.step("tear down", () => { - reset(); + await t.step("tear down", async () => { + await reset(); }); }); ~~~~ @@ -350,7 +384,7 @@ import { type LogRecord, configure } from "@logtape/logtape"; const buffer: LogRecord[] = []; -configure({ +await configure({ sinks: { buffer: buffer.push.bind(buffer), }, diff --git a/logtape/config.test.ts b/logtape/config.test.ts index 5177c28..2583c8b 100644 --- a/logtape/config.test.ts +++ b/logtape/config.test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "@std/assert/assert-equals"; -import { assertThrows } from "@std/assert/assert-throws"; +import { assertRejects } from "@std/assert/assert-rejects"; import { ConfigError, configure, reset } from "./config.ts"; import type { Filter } from "./filter.ts"; import { LoggerImpl } from "./logger.ts"; @@ -9,7 +9,7 @@ import type { Sink } from "./sink.ts"; Deno.test("configure()", async (t) => { let disposed = 0; - await t.step("test", () => { + await t.step("test", async () => { const a: Sink = () => {}; const b: Sink & Disposable = () => {}; b[Symbol.dispose] = () => ++disposed; @@ -18,7 +18,7 @@ Deno.test("configure()", async (t) => { const x: Filter = () => true; const y: Filter & Disposable = () => true; y[Symbol.dispose] = () => ++disposed; - configure({ + await configure({ sinks: { a, b, c }, filters: { x, y }, loggers: [ @@ -62,8 +62,8 @@ Deno.test("configure()", async (t) => { ]); }); - await t.step("reconfigure", () => { - assertThrows( + await t.step("reconfigure", async () => { + await assertRejects( () => configure({ sinks: {}, @@ -76,7 +76,7 @@ Deno.test("configure()", async (t) => { assertEquals(disposed, 0); // No exception if reset is true: - configure({ + await configure({ sinks: {}, filters: {}, loggers: [{ category: "my-app" }], @@ -85,12 +85,12 @@ Deno.test("configure()", async (t) => { assertEquals(disposed, 2); }); - await t.step("tear down", () => { - reset(); + await t.step("tear down", async () => { + await reset(); }); - await t.step("misconfiguration", () => { - assertThrows( + await t.step("misconfiguration", async () => { + await assertRejects( () => configure({ // deno-lint-ignore no-explicit-any @@ -108,7 +108,7 @@ Deno.test("configure()", async (t) => { "Sink not found: invalid", ); - assertThrows( + await assertRejects( () => configure({ sinks: {}, @@ -129,24 +129,27 @@ Deno.test("configure()", async (t) => { const metaCategories = [[], ["logtape"], ["logtape", "meta"]]; for (const metaCategory of metaCategories) { - await t.step("meta configuration: " + JSON.stringify(metaCategory), () => { - configure({ - sinks: {}, - filters: {}, - loggers: [ - { - category: metaCategory, - sinks: [], - filters: [], - }, - ], - }); + await t.step( + "meta configuration: " + JSON.stringify(metaCategory), + async () => { + await configure({ + sinks: {}, + filters: {}, + loggers: [ + { + category: metaCategory, + sinks: [], + filters: [], + }, + ], + }); - assertEquals(LoggerImpl.getLogger(["logger", "meta"]).sinks, []); - }); + assertEquals(LoggerImpl.getLogger(["logger", "meta"]).sinks, []); + }, + ); - await t.step("tear down", () => { - reset(); + await t.step("tear down", async () => { + await reset(); }); } }); diff --git a/logtape/config.ts b/logtape/config.ts index 27fd6f9..e8ca2f2 100644 --- a/logtape/config.ts +++ b/logtape/config.ts @@ -69,6 +69,11 @@ let configured = false; */ const disposables: Set = new Set(); +/** + * Async disposables to dispose when resetting the configuration. + */ +const asyncDisposables: Set = new Set(); + /** * Configure the loggers with the specified configuration. * @@ -77,7 +82,7 @@ const disposables: Set = new Set(); * * @example * ```typescript - * configure({ + * await configure({ * sinks: { * console: getConsoleSink(), * }, @@ -108,15 +113,16 @@ const disposables: Set = new Set(); * * @param config The configuration. */ -export function configure( - config: Config, -) { +export async function configure< + TSinkId extends string, + TFilterId extends string, +>(config: Config): Promise { if (configured && !config.reset) { throw new ConfigError( "Already configured; if you want to reset, turn on the reset flag.", ); } - reset(); + await reset(); configured = true; let metaConfigured = false; @@ -135,7 +141,7 @@ export function configure( for (const sinkId of cfg.sinks ?? []) { const sink = config.sinks[sinkId]; if (!sink) { - reset(); + await reset(); throw new ConfigError(`Sink not found: ${sinkId}.`); } logger.sinks.push(sink); @@ -144,7 +150,7 @@ export function configure( for (const filterId of cfg.filters ?? []) { const filter = config.filters[filterId]; if (filter === undefined) { - reset(); + await reset(); throw new ConfigError(`Filter not found: ${filterId}.`); } logger.filters.push(toFilter(filter)); @@ -152,13 +158,18 @@ export function configure( } for (const sink of Object.values(config.sinks)) { + if (Symbol.asyncDispose in sink) { + asyncDisposables.add(sink as AsyncDisposable); + } if (Symbol.dispose in sink) disposables.add(sink as Disposable); } for (const filter of Object.values(config.filters)) { - if ( - filter != null && typeof filter !== "string" && Symbol.dispose in filter - ) disposables.add(filter as Disposable); + if (filter == null || typeof filter === "string") continue; + if (Symbol.asyncDispose in filter) { + asyncDisposables.add(filter as AsyncDisposable); + } + if (Symbol.dispose in filter) disposables.add(filter as Disposable); } if ("process" in globalThis) { // @ts-ignore: It's fine to use process in Node @@ -188,8 +199,8 @@ export function configure( /** * Reset the configuration. Mostly for testing purposes. */ -export function reset() { - dispose(); +export async function reset(): Promise { + await dispose(); LoggerImpl.getLogger([]).resetDescendants(); configured = false; } @@ -197,9 +208,15 @@ export function reset() { /** * Dispose of the disposables. */ -export function dispose() { +export async function dispose(): Promise { for (const disposable of disposables) disposable[Symbol.dispose](); disposables.clear(); + const promises: PromiseLike[] = []; + for (const disposable of asyncDisposables) { + promises.push(disposable[Symbol.asyncDispose]()); + asyncDisposables.delete(disposable); + } + await Promise.all(promises); } /** diff --git a/logtape/mod.ts b/logtape/mod.ts index 6da1011..4d454f6 100644 --- a/logtape/mod.ts +++ b/logtape/mod.ts @@ -2,6 +2,7 @@ export { type Config, ConfigError, configure, + dispose, type LoggerConfig, reset, } from "./config.ts";