Skip to content

Commit

Permalink
Asynchronously disposable sinks
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Apr 19, 2024
1 parent 9698037 commit 9c90023
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 53 deletions.
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`
(was `void`).
- The return type of `reset()` function became `Promise<void>`
(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
-------------
Expand Down
60 changes: 47 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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);
Expand All @@ -187,7 +187,7 @@ provides a console sink:
~~~~ typescript
import { configure, getConsoleSink } from "@logtape/logtape";

configure({
await configure({
sinks: {
console: getConsoleSink(),
},
Expand All @@ -209,7 +209,7 @@ messages to the standard error:

~~~~ typescript
// Deno:
configure({
await configure({
sinks: {
stream: getStreamSink(Deno.stderr.writable),
},
Expand All @@ -221,7 +221,7 @@ configure({
// Node.js:
import stream from "node:stream";

configure({
await configure({
sinks: {
stream: getStreamSink(stream.Writable.toWeb(process.stderr)),
},
Expand Down Expand Up @@ -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"),
},
Expand All @@ -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,
Expand All @@ -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
-------
Expand All @@ -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();
});
});
~~~~
Expand All @@ -350,7 +384,7 @@ import { type LogRecord, configure } from "@logtape/logtape";

const buffer: LogRecord[] = [];

configure({
await configure({
sinks: {
buffer: buffer.push.bind(buffer),
},
Expand Down
57 changes: 30 additions & 27 deletions logtape/config.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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: [
Expand Down Expand Up @@ -62,8 +62,8 @@ Deno.test("configure()", async (t) => {
]);
});

await t.step("reconfigure", () => {
assertThrows(
await t.step("reconfigure", async () => {
await assertRejects(
() =>
configure({
sinks: {},
Expand All @@ -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" }],
Expand All @@ -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
Expand All @@ -108,7 +108,7 @@ Deno.test("configure()", async (t) => {
"Sink not found: invalid",
);

assertThrows(
await assertRejects(
() =>
configure({
sinks: {},
Expand All @@ -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();
});
}
});
43 changes: 30 additions & 13 deletions logtape/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ let configured = false;
*/
const disposables: Set<Disposable> = new Set();

/**
* Async disposables to dispose when resetting the configuration.
*/
const asyncDisposables: Set<AsyncDisposable> = new Set();

/**
* Configure the loggers with the specified configuration.
*
Expand All @@ -77,7 +82,7 @@ const disposables: Set<Disposable> = new Set();
*
* @example
* ```typescript
* configure({
* await configure({
* sinks: {
* console: getConsoleSink(),
* },
Expand Down Expand Up @@ -108,15 +113,16 @@ const disposables: Set<Disposable> = new Set();
*
* @param config The configuration.
*/
export function configure<TSinkId extends string, TFilterId extends string>(
config: Config<TSinkId, TFilterId>,
) {
export async function configure<
TSinkId extends string,
TFilterId extends string,
>(config: Config<TSinkId, TFilterId>): Promise<void> {
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;
Expand All @@ -135,7 +141,7 @@ export function configure<TSinkId extends string, TFilterId extends string>(
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);
Expand All @@ -144,21 +150,26 @@ export function configure<TSinkId extends string, TFilterId extends string>(
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));
}
}

for (const sink of Object.values<Sink>(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<FilterLike>(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
Expand Down Expand Up @@ -188,18 +199,24 @@ export function configure<TSinkId extends string, TFilterId extends string>(
/**
* Reset the configuration. Mostly for testing purposes.
*/
export function reset() {
dispose();
export async function reset(): Promise<void> {
await dispose();
LoggerImpl.getLogger([]).resetDescendants();
configured = false;
}

/**
* Dispose of the disposables.
*/
export function dispose() {
export async function dispose(): Promise<void> {
for (const disposable of disposables) disposable[Symbol.dispose]();
disposables.clear();
const promises: PromiseLike<void>[] = [];
for (const disposable of asyncDisposables) {
promises.push(disposable[Symbol.asyncDispose]());
asyncDisposables.delete(disposable);
}
await Promise.all(promises);
}

/**
Expand Down
1 change: 1 addition & 0 deletions logtape/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
type Config,
ConfigError,
configure,
dispose,
type LoggerConfig,
reset,
} from "./config.ts";
Expand Down

0 comments on commit 9c90023

Please sign in to comment.