diff --git a/logtape/config.test.ts b/logtape/config.test.ts index ce057ae..f33f97f 100644 --- a/logtape/config.test.ts +++ b/logtape/config.test.ts @@ -1,12 +1,15 @@ import { assertEquals } from "@std/assert/assert-equals"; import { assertRejects } from "@std/assert/assert-rejects"; import { assertStrictEquals } from "@std/assert/assert-strict-equals"; +import assert from "node:assert/strict"; import { type Config, ConfigError, configure, + configureSync, getConfig, reset, + resetSync, } from "./config.ts"; import type { Filter } from "./filter.ts"; import { LoggerImpl } from "./logger.ts"; @@ -218,3 +221,226 @@ Deno.test("configure()", async (t) => { }); } }); +Deno.test("configureSync()", async (t) => { + let disposed = 0; + + await t.step("test", () => { + const bLogs: LogRecord[] = []; + const b: Sink & Disposable = (record) => bLogs.push(record); + b[Symbol.dispose] = () => ++disposed; + const cLogs: LogRecord[] = []; + const c: Sink = cLogs.push.bind(cLogs); + const y: Filter & Disposable = () => true; + y[Symbol.dispose] = () => ++disposed; + const config: Config = { + sinks: { b, c }, + filters: { y, debug: "debug" }, + loggers: [ + { + category: ["my-app", "foo"], + sinks: ["b"], + parentSinks: "override", + filters: ["y"], + }, + { + category: ["my-app", "bar"], + sinks: ["c"], + filters: ["debug"], + level: "info", // deprecated + lowestLevel: "info", + }, + ], + }; + configureSync(config); + + const foo = LoggerImpl.getLogger(["my-app", "foo"]); + assertEquals(foo.sinks, [b]); + assertEquals(foo.filters, [y]); + assertEquals(foo.lowestLevel, "debug"); + const bar = LoggerImpl.getLogger(["my-app", "bar"]); + assertEquals(bar.sinks, [c]); + assertEquals(bar.lowestLevel, "info"); + bar.debug("ignored"); + assertEquals(bLogs, []); + assertEquals(cLogs, []); + foo.warn("logged"); + assertEquals(bLogs, [ + { + level: "warning", + category: ["my-app", "foo"], + message: ["logged"], + rawMessage: "logged", + properties: {}, + timestamp: bLogs[0].timestamp, + }, + ]); + assertEquals(cLogs, []); + bar.info("logged"); + assertEquals(bLogs, [ + { + level: "warning", + category: ["my-app", "foo"], + message: ["logged"], + rawMessage: "logged", + properties: {}, + timestamp: bLogs[0].timestamp, + }, + ]); + assertEquals(cLogs, [ + { + level: "info", + category: ["my-app", "bar"], + message: ["logged"], + rawMessage: "logged", + properties: {}, + timestamp: cLogs[0].timestamp, + }, + ]); + assertStrictEquals(getConfig(), config); + }); + + await t.step("reconfigure", () => { + assert.throws( + () => + configureSync({ + sinks: {}, + loggers: [{ category: "my-app" }], + }), + ConfigError, + "Already configured", + ); + assertEquals(disposed, 0); + + // No exception if reset is true: + const config = { + sinks: {}, + loggers: [{ category: "my-app" }], + reset: true, + }; + configureSync(config); + assertEquals(disposed, 2); + assertStrictEquals(getConfig(), config); + }); + + await t.step("tear down", () => { + resetSync(); + assertStrictEquals(getConfig(), null); + }); + + await t.step("misconfiguration", () => { + assert.throws( + () => + configureSync({ + // deno-lint-ignore no-explicit-any + sinks: {} as any, + loggers: [ + { + category: "my-app", + sinks: ["invalid"], + }, + ], + reset: true, + }), + ConfigError, + "Sink not found: invalid", + ); + assertStrictEquals(getConfig(), null); + + assert.throws( + () => + configureSync({ + sinks: {}, + // deno-lint-ignore no-explicit-any + filters: {} as any, + loggers: [ + { + category: "my-app", + filters: ["invalid"], + }, + ], + reset: true, + }), + ConfigError, + "Filter not found: invalid", + ); + assertStrictEquals(getConfig(), null); + }); + + const metaCategories = [[], ["logtape"], ["logtape", "meta"]]; + for (const metaCategory of metaCategories) { + await t.step("meta configuration: " + JSON.stringify(metaCategory), () => { + const config = { + sinks: {}, + loggers: [ + { + category: metaCategory, + sinks: [], + filters: [], + }, + ], + }; + configureSync(config); + + assertEquals(LoggerImpl.getLogger(["logger", "meta"]).sinks, []); + assertStrictEquals(getConfig(), config); + }); + + await t.step("tear down", () => { + resetSync(); + assertStrictEquals(getConfig(), null); + }); + } + + await t.step("no async sinks", () => { + const aLogs: LogRecord[] = []; + const a: Sink & AsyncDisposable = (record) => aLogs.push(record); + a[Symbol.asyncDispose] = () => { + return Promise.resolve(); + }; + const config: Config = { + sinks: { a }, + loggers: [ + { + category: "my-app", + sinks: ["a"], + }, + ], + }; + + assert.throws( + () => configureSync(config), + ConfigError, + "Async disposables cannot be used with configureSync", + ); + assertStrictEquals(getConfig(), null); + }); + + await t.step("no async filters", () => { + const aLogs: LogRecord[] = []; + const a: Sink & Disposable = (record) => aLogs.push(record); + a[Symbol.dispose] = () => ++disposed; + const x: Filter & AsyncDisposable = () => true; + x[Symbol.asyncDispose] = () => { + ++disposed; + return Promise.resolve(); + }; + const config: Config = { + sinks: { a }, + filters: { x }, + loggers: [ + { + category: "my-app", + sinks: ["a"], + filters: ["x"], + }, + ], + }; + + assert.throws( + () => configureSync(config), + ConfigError, + "Async disposables cannot be used with configureSync", + ); + assertStrictEquals(getConfig(), null); + }); +}); diff --git a/logtape/config.ts b/logtape/config.ts index f0e6c2e..70b0dbf 100644 --- a/logtape/config.ts +++ b/logtape/config.ts @@ -108,6 +108,58 @@ const disposables: Set = new Set(); */ const asyncDisposables: Set = new Set(); +/** + * Shared logic between configure/configureSync. Registers dispose to be called + * when the parent application exits, and logs warning messages to the meta logger. + */ +const finalizeConfigure = (metaConfigured: boolean, levelUsed: boolean) => { + if ("process" in globalThis && !("Deno" in globalThis)) { + // @ts-ignore: It's fine to use process in Node + // deno-lint-ignore no-process-globals + process.on("exit", dispose); + } else { + // @ts-ignore: It's fine to addEventListener() on the browser/Deno + addEventListener("unload", dispose); + } + const meta = LoggerImpl.getLogger(["logtape", "meta"]); + if (!metaConfigured) { + meta.sinks.push(getConsoleSink()); + } + + meta.info( + "LogTape loggers are configured. Note that LogTape itself uses the meta " + + "logger, which has category {metaLoggerCategory}. The meta logger " + + "purposes to log internal errors such as sink exceptions. If you " + + "are seeing this message, the meta logger is somehow configured. " + + "It's recommended to configure the meta logger with a separate sink " + + "so that you can easily notice if logging itself fails or is " + + "misconfigured. To turn off this message, configure the meta logger " + + "with higher log levels than {dismissLevel}. See also " + + ".", + { metaLoggerCategory: ["logtape", "meta"], dismissLevel: "info" }, + ); + + if (levelUsed) { + meta.warn( + "The level option is deprecated in favor of lowestLevel option. " + + "Please update your configuration. See also " + + ".", + ); + } +}; + +/** + * Check if a config is for the meta logger. + */ +const isLoggerConfigMeta = ( + cfg: LoggerConfig, +): boolean => + cfg.category.length === 0 || + (cfg.category.length === 1 && cfg.category[0] === "logtape") || + (cfg.category.length === 2 && + cfg.category[0] === "logtape" && + cfg.category[1] === "meta"); + /** * Configure the loggers with the specified configuration. * @@ -163,13 +215,7 @@ export async function configure< let levelUsed = false; for (const cfg of config.loggers) { - if ( - cfg.category.length === 0 || - (cfg.category.length === 1 && cfg.category[0] === "logtape") || - (cfg.category.length === 2 && - cfg.category[0] === "logtape" && - cfg.category[1] === "meta") - ) { + if (isLoggerConfigMeta(cfg)) { metaConfigured = true; } const logger = LoggerImpl.getLogger(cfg.category); @@ -217,38 +263,111 @@ export async function configure< if (Symbol.dispose in filter) disposables.add(filter as Disposable); } - if ("process" in globalThis && !("Deno" in globalThis)) { // @ts-ignore: It's fine to use process in Node - // deno-lint-ignore no-process-globals - process.on("exit", dispose); - } else { // @ts-ignore: It's fine to addEventListener() on the browser/Deno - addEventListener("unload", dispose); + finalizeConfigure(metaConfigured, levelUsed); +} + +/** + * Configure sync loggers with the specified configuration. + * + * Note that if the given sinks or filters are disposable, they will be + * disposed when the configuration is reset, or when the process exits. + * + * Also note that passing async sinks or filters will throw. If + * neccessary use {@link resetSync} or {@link disposeSync}. + * + * @example + * ```typescript + * await configure({ + * sinks: { + * console: getConsoleSink(), + * }, + * loggers: [ + * { + * category: "my-app", + * sinks: ["console"], + * level: "info", + * }, + * { + * category: "logtape", + * sinks: ["console"], + * level: "error", + * }, + * ], + * }); + * ``` + * + * @param config The configuration. + */ +export function configureSync( + config: Config, +): void { + if (currentConfig != null && !config.reset) { + throw new ConfigError( + "Already configured; if you want to reset, turn on the reset flag.", + ); } + resetSync(); + currentConfig = config; - const meta = LoggerImpl.getLogger(["logtape", "meta"]); - if (!metaConfigured) { - meta.sinks.push(getConsoleSink()); + let metaConfigured = false; + let levelUsed = false; + + for (const cfg of config.loggers) { + if (isLoggerConfigMeta(cfg)) { + metaConfigured = true; + } + const logger = LoggerImpl.getLogger(cfg.category); + for (const sinkId of cfg.sinks ?? []) { + const sink = config.sinks[sinkId]; + if (!sink) { + resetSync(); + throw new ConfigError(`Sink not found: ${sinkId}.`); + } + logger.sinks.push(sink); + } + logger.parentSinks = cfg.parentSinks ?? "inherit"; + if (cfg.lowestLevel !== undefined) { + logger.lowestLevel = cfg.lowestLevel; + } + if (cfg.level !== undefined) { + levelUsed = true; + logger.filters.push(toFilter(cfg.level)); + } + for (const filterId of cfg.filters ?? []) { + const filter = config.filters?.[filterId]; + if (filter === undefined) { + resetSync(); + throw new ConfigError(`Filter not found: ${filterId}.`); + } + logger.filters.push(toFilter(filter)); + } + strongRefs.add(logger); } - meta.info( - "LogTape loggers are configured. Note that LogTape itself uses the meta " + - "logger, which has category {metaLoggerCategory}. The meta logger " + - "purposes to log internal errors such as sink exceptions. If you " + - "are seeing this message, the meta logger is somehow configured. " + - "It's recommended to configure the meta logger with a separate sink " + - "so that you can easily notice if logging itself fails or is " + - "misconfigured. To turn off this message, configure the meta logger " + - "with higher log levels than {dismissLevel}. See also " + - ".", - { metaLoggerCategory: ["logtape", "meta"], dismissLevel: "info" }, - ); + LoggerImpl.getLogger().contextLocalStorage = config.contextLocalStorage; - if (levelUsed) { - meta.warn( - "The level option is deprecated in favor of lowestLevel option. " + - "Please update your configuration. See also " + - ".", - ); + for (const sink of Object.values(config.sinks)) { + if (Symbol.asyncDispose in sink) { + resetSync(); + throw new ConfigError( + "Async disposables cannot be used with configureSync.", + ); + } + if (Symbol.dispose in sink) disposables.add(sink as Disposable); + } + + for (const filter of Object.values(config.filters ?? {})) { + if (filter == null || typeof filter === "string") continue; + if (Symbol.asyncDispose in filter) { + resetSync(); + throw new ConfigError( + "Async disposables cannot be used with configureSync.", + ); + } + if (Symbol.dispose in filter) disposables.add(filter as Disposable); } + + finalizeConfigure(metaConfigured, levelUsed); } /** @@ -271,6 +390,19 @@ export async function reset(): Promise { currentConfig = null; } +/** + * Reset the configuration. Mostly for testing purposes. Will not clear async sinks, only use + * with sync sinks. + */ +export function resetSync(): void { + disposeSync(); + const rootLogger = LoggerImpl.getLogger([]); + rootLogger.resetDescendants(); + delete rootLogger.contextLocalStorage; + strongRefs.clear(); + currentConfig = null; +} + /** * Dispose of the disposables. */ @@ -285,6 +417,14 @@ export async function dispose(): Promise { await Promise.all(promises); } +/** + * Dispose of the sync disposables. Async disposables will be untouched, use `dispose` if you have async sinks. + */ +export function disposeSync(): void { + for (const disposable of disposables) disposable[Symbol.dispose](); + disposables.clear(); +} + /** * A configuration error. */ diff --git a/logtape/mod.ts b/logtape/mod.ts index bc01d00..e40f504 100644 --- a/logtape/mod.ts +++ b/logtape/mod.ts @@ -2,10 +2,13 @@ export { type Config, ConfigError, configure, + configureSync, dispose, + disposeSync, getConfig, type LoggerConfig, reset, + resetSync, } from "./config.ts"; export { type ContextLocalStorage, withContext } from "./context.ts"; export { getFileSink, getRotatingFileSink } from "./filesink.jsr.ts";