From 5a001a28ee508b32dd8e690db8c5912b550378f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steven=20Weing=C3=A4rtner?= Date: Tue, 13 Oct 2026 15:57:33 +0100 Subject: [PATCH] feat!: validate the configuration with zod The merged configuration is validated when a Logger is created, after env() placeholders are resolved. All problems are reported at once with their path, unknown options are rejected. Plugins hook into the validation with a static `configSchema`: the logger loads the configured plugins first and validates their options together with its own. The Sentry plugin defines its options this way and accepts strings for booleans and numbers, so they can be set with env(). BREAKING CHANGE: Unknown options and invalid values throw an error. Only the standard log levels are allowed. Plugins that read their own options from the configuration need to provide a `configSchema`. Co-Authored-By: Claude Opus 5.5 --- eslint.config.js | 6 + package-lock.json | 56 ++---- package.json | 1 + packages/node-logger-plugin-sentry/README.md | 30 ++++ .../node-logger-plugin-sentry/package.json | 3 +- .../src/index.test.ts | 59 ++++++- .../node-logger-plugin-sentry/src/index.ts | 114 ++++++------- packages/node-logger/README.md | 47 +++++ packages/node-logger/package.json | 3 +- packages/node-logger/src/config.test.ts | 160 +++++++++++------- packages/node-logger/src/config.ts | 99 +++++------ packages/node-logger/src/index.ts | 1 + packages/node-logger/src/schema.ts | 64 +++++++ packages/node-logger/src/types.ts | 43 +++-- vitest.config.ts | 7 + 15 files changed, 448 insertions(+), 245 deletions(-) create mode 100644 packages/node-logger/src/schema.ts diff --git a/eslint.config.js b/eslint.config.js index 9fffed5..b9889e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,12 @@ export default defineConfig([ quoteProps: 'as-needed', }), { + settings: { + 'import-x/resolver': { + // Resolve workspace packages to their sources, so no build is needed for linting + typescript: { conditionNames: ['@brainbits/source', 'types', 'import', 'node', 'default'] }, + }, + }, languageOptions: { parserOptions: { projectService: { diff --git a/package-lock.json b/package-lock.json index 10313ae..8606e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint-plugin-import-x": "^4.17.1", "typescript": "~6.0.3", "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", "vitest": "^5.0.4" } }, @@ -772,7 +773,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -790,7 +790,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -808,7 +807,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -826,7 +824,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -844,7 +841,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -862,7 +858,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -883,7 +878,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -904,7 +898,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -925,7 +918,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -946,7 +938,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -967,7 +958,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -988,7 +978,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1006,7 +995,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1024,7 +1012,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1042,7 +1029,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1052,8 +1038,7 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@sentry/bundler-plugins": { "version": "11.7.0", @@ -2185,7 +2170,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8" } @@ -2609,7 +2593,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -2790,7 +2773,6 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2828,7 +2810,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2850,7 +2831,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2872,7 +2852,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2894,7 +2873,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2916,7 +2894,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2941,7 +2918,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2966,7 +2942,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2991,7 +2966,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3016,7 +2990,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3038,7 +3011,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3060,7 +3032,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3158,7 +3129,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3330,8 +3300,7 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", @@ -3366,7 +3335,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", @@ -3432,7 +3400,6 @@ "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" @@ -3467,7 +3434,6 @@ "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/oxc-project" } @@ -3727,7 +3693,6 @@ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -3935,12 +3900,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/node-logger": { "name": "@brainbits/node-logger", "version": "2.5.1", "license": "MIT", "dependencies": { - "deepmerge": "^4.3.1" + "deepmerge": "^4.3.1", + "zod": "^4.6.7" }, "engines": { "node": ">=22.12.0" @@ -3965,7 +3940,8 @@ "version": "2.5.1", "license": "MIT", "dependencies": { - "@sentry/node": "^11.7.0" + "@sentry/node": "^11.7.0", + "zod": "^4.6.7" }, "engines": { "node": ">=22.12.0" diff --git a/package.json b/package.json index 909cdf5..efeff78 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "eslint-plugin-import-x": "^4.17.1", "typescript": "~6.0.3", "typescript-eslint": "^8.71.2", + "vite": "^8.3.4", "vitest": "^5.0.4" } } diff --git a/packages/node-logger-plugin-sentry/README.md b/packages/node-logger-plugin-sentry/README.md index 9e8042f..806b298 100644 --- a/packages/node-logger-plugin-sentry/README.md +++ b/packages/node-logger-plugin-sentry/README.md @@ -1 +1,31 @@ # `node-logger-plugin-sentry` + +Sends log events to [Sentry](https://sentry.io): events up to the `exceptionLevel` as exceptions, all other events as +breadcrumbs of the next exception. + +```json +"nodeLogger": { + "plugins": [ + "@brainbits/node-logger-plugin-sentry" + ], + "sentry": { + "dsn": "env(SENTRY_DSN)" + } +} +``` + +## Options + +| Option | Default | Description | +|----------------------|----------------|---------------------------------------------------------------------| +| `dsn` | `''` | Sentry DSN, Sentry is disabled without it | +| `environment` | `'dev'` | Sentry environment | +| `release` | `null` | Sentry release | +| `debug` | `false` | Enables the debug mode of the Sentry SDK | +| `exceptionLevel` | `'error'` | Events up to this level are sent as exceptions | +| `maxBreadcrumbs` | `50` | Maximum number of breadcrumbs sent with an exception | +| `breadcrumbLevelMap` | all levels | Maps log levels to Sentry breadcrumb levels, unmapped levels are skipped | +| `tags` | `{}` | Tags added to every exception | + +The options are validated together with the logger configuration. Booleans and numbers may be strings, so they can be +set with `env()`. diff --git a/packages/node-logger-plugin-sentry/package.json b/packages/node-logger-plugin-sentry/package.json index c259340..dcd963b 100644 --- a/packages/node-logger-plugin-sentry/package.json +++ b/packages/node-logger-plugin-sentry/package.json @@ -31,7 +31,8 @@ "author": "Brainbits ", "license": "MIT", "dependencies": { - "@sentry/node": "^11.7.0" + "@sentry/node": "^11.7.0", + "zod": "^4.6.7" }, "peerDependencies": { "@brainbits/node-logger": "^2.2.3" diff --git a/packages/node-logger-plugin-sentry/src/index.test.ts b/packages/node-logger-plugin-sentry/src/index.test.ts index e6dc9d4..dda8a76 100644 --- a/packages/node-logger-plugin-sentry/src/index.test.ts +++ b/packages/node-logger-plugin-sentry/src/index.test.ts @@ -1,7 +1,7 @@ -import type { MergedConfig } from '@brainbits/node-logger'; +import type { PluginConstructor, ValidatedConfig } from '@brainbits/node-logger'; import * as Sentry from '@sentry/node'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import PluginSentry from './index.js'; +import PluginSentry, { sentryConfigSchema } from './index.js'; vi.mock('@sentry/node', () => ({ init: vi.fn(), @@ -13,7 +13,7 @@ vi.mock('@sentry/node', () => ({ describe('PluginSentry', () => { let scope: Record<'setLevel' | 'setUser' | 'setExtra' | 'setTag', ReturnType>; - let config: MergedConfig & { sentry: { tags: Record } }; + let config: ValidatedConfig & { sentry: { tags: Record } }; beforeEach(() => { config = { @@ -56,6 +56,59 @@ describe('PluginSentry', () => { expect(integrations([{ name: 'Console' }, { name: 'Http' }])).toEqual([{ name: 'Http' }]); }); + it('should provide its config schema to the logger', () => { + const pluginClass: PluginConstructor = PluginSentry; + + expect(pluginClass.configSchema?.parse({})).toEqual({ + sentry: sentryConfigSchema.parse({}), + }); + }); + + it('should apply the defaults', () => { + expect(sentryConfigSchema.parse({})).toEqual({ + debug: false, + dsn: '', + environment: 'dev', + release: null, + maxBreadcrumbs: 50, + exceptionLevel: 'error', + breadcrumbLevelMap: { + emergency: 'error', + alert: 'error', + critical: 'error', + error: 'error', + warning: 'warning', + notice: 'info', + info: 'info', + debug: 'debug', + }, + tags: {}, + }); + }); + + it('should accept strings from env() for booleans and numbers', () => { + expect(sentryConfigSchema.parse({ debug: 'false', maxBreadcrumbs: '20' })) + .toMatchObject({ debug: false, maxBreadcrumbs: 20 }); + }); + + it.each([ + ['an unknown option', { dns: 'https://example.com' }], + ['an unknown exceptionLevel', { exceptionLevel: 'fatal' }], + ['an invalid boolean', { debug: 'maybe' }], + ['an invalid number', { maxBreadcrumbs: 'many' }], + ['an unknown breadcrumb level', { breadcrumbLevelMap: { info: 'notice' } }], + ])('should reject %s', (_, sentry) => { + expect(sentryConfigSchema.safeParse(sentry).success).toBe(false); + }); + + it('should throw an error if the exceptionLevel is not one of the configured levels', () => { + config.sentry = { ...config.sentry, exceptionLevel: 'critical' }; + + expect(() => new PluginSentry(config)).toThrow( + 'Invalid node-logger configuration: sentry.exceptionLevel "critical" is not one of the configured levels', + ); + }); + it('should not initialize Sentry twice', () => { vi.mocked(Sentry.isInitialized).mockReturnValueOnce(true); diff --git a/packages/node-logger-plugin-sentry/src/index.ts b/packages/node-logger-plugin-sentry/src/index.ts index 8926fa5..8ee0c56 100644 --- a/packages/node-logger-plugin-sentry/src/index.ts +++ b/packages/node-logger-plugin-sentry/src/index.ts @@ -1,71 +1,70 @@ -import type { LogEvent, LogLevel, MergedConfig, Plugin } from '@brainbits/node-logger'; +import { logLevelSchema } from '@brainbits/node-logger'; +import type { LogEvent, Plugin, ValidatedConfig } from '@brainbits/node-logger'; import * as Sentry from '@sentry/node'; - -type BreadcrumbLevel = 'error' | 'warning' | 'info' | 'debug'; - -export interface SentryConfig { - debug: boolean; - dsn: string; - environment: string; - release: string | null; - maxBreadcrumbs: number; - exceptionLevel: LogLevel; - breadcrumbLevelMap: Partial>; - tags: Record; -} +import { z } from 'zod'; + +// Accepts strings as well, so the values can be set with env() +const booleanSchema = z.union([z.boolean(), z.stringbool()]); + +export const sentryConfigSchema = z.strictObject({ + debug: booleanSchema.default(false), + dsn: z.string().default(''), + environment: z.string().default('dev'), + // null instead of undefined keeps Sentry from detecting a release on its own + release: z.string().nullable().default(null), + maxBreadcrumbs: z.coerce.number().int().nonnegative().default(50), + exceptionLevel: logLevelSchema.default('error'), + breadcrumbLevelMap: z.partialRecord(logLevelSchema, z.enum(['error', 'warning', 'info', 'debug'])).default({ + emergency: 'error', + alert: 'error', + critical: 'error', + error: 'error', + warning: 'warning', + notice: 'info', + info: 'info', + debug: 'debug', + }), + tags: z.record(z.string(), z.string()).default({}), +}); + +export type SentryConfig = z.output; declare module '@brainbits/node-logger' { interface Config { - sentry?: Partial; + sentry?: z.input; } } -type PluginSentryConfig = Omit & { sentry: SentryConfig }; - export default class PluginSentry implements Plugin { - defaults: SentryConfig = { - debug: false, - dsn: '', - environment: 'dev', - release: null, - maxBreadcrumbs: 50, - exceptionLevel: 'error', - breadcrumbLevelMap: { - emergency: 'error', - alert: 'error', - critical: 'error', - error: 'error', - warning: 'warning', - notice: 'info', - info: 'info', - debug: 'debug', - }, - tags: {}, - }; - - private readonly config: PluginSentryConfig; + static readonly configSchema = z.object({ + sentry: sentryConfigSchema.prefault({}), + }); + + private readonly config: ValidatedConfig; + + private readonly sentry: SentryConfig; private readonly breadCrumbs: LogEvent[] = []; - constructor(config: MergedConfig) { - const { sentry, ...rest } = config; + constructor(config: ValidatedConfig) { + this.config = config; + // Already validated by the logger, parsed again to get the defaults when the plugin is used on its own + this.sentry = sentryConfigSchema.parse(config.sentry ?? {}); - this.config = { - sentry: { - ...this.defaults, - ...sentry, - }, - ...rest, - }; + if (!config.levels.includes(this.sentry.exceptionLevel)) { + throw new Error( + `Invalid node-logger configuration: sentry.exceptionLevel "${this.sentry.exceptionLevel}" ` + + 'is not one of the configured levels', + ); + } if (!Sentry.isInitialized()) { Sentry.init({ - debug: this.config.sentry.debug, - dsn: this.config.sentry.dsn, - environment: this.config.sentry.environment, - // null instead of undefined keeps Sentry from detecting a release on its own - release: this.config.sentry.release as string | undefined, - maxBreadcrumbs: this.config.sentry.maxBreadcrumbs, + debug: this.sentry.debug, + dsn: this.sentry.dsn, + environment: this.sentry.environment, + release: this.sentry.release as string | undefined, + maxBreadcrumbs: this.sentry.maxBreadcrumbs, integrations(integrations) { return integrations .filter(integration => integration.name !== 'Console'); @@ -84,13 +83,14 @@ export default class PluginSentry implements Plugin { } private isException({ level }: LogEvent): boolean { - const { levels, sentry } = this.config; + const { levels } = this.config; - return levels.indexOf(sentry.exceptionLevel) >= levels.indexOf(level); + return levels.indexOf(this.sentry.exceptionLevel) >= levels.indexOf(level); } private logException({ level, message, meta = {} }: LogEvent): void { - const { sentry, context = {} } = this.config; + const { sentry } = this; + const { context = {} } = this.config; Sentry.withScope((scope) => { const { user: configUser, ...configExtras } = context; @@ -132,7 +132,7 @@ export default class PluginSentry implements Plugin { } private addBreadcrumb(event: LogEvent): void { - const { sentry } = this.config; + const { sentry } = this; if (!Object.keys(sentry.breadcrumbLevelMap).includes(event.level)) { return; diff --git a/packages/node-logger/README.md b/packages/node-logger/README.md index 1e640b6..c5ad555 100644 --- a/packages/node-logger/README.md +++ b/packages/node-logger/README.md @@ -82,6 +82,20 @@ You can set your own `ENV_VARS` in your package.json with `env(, ; + } +} + +export default class MyPlugin implements Plugin { + static readonly configSchema = z.object({ myPlugin: myPluginSchema.prefault({}) }); + + constructor(config: ValidatedConfig) { + // ... + } + + log(event: LogEvent): void { + // ... + } +} +``` diff --git a/packages/node-logger/package.json b/packages/node-logger/package.json index 793551f..aa91476 100644 --- a/packages/node-logger/package.json +++ b/packages/node-logger/package.json @@ -31,7 +31,8 @@ "author": "Brainbits ", "license": "MIT", "dependencies": { - "deepmerge": "^4.3.1" + "deepmerge": "^4.3.1", + "zod": "^4.6.7" }, "publishConfig": { "access": "public" diff --git a/packages/node-logger/src/config.test.ts b/packages/node-logger/src/config.test.ts index b8e716f..d6f0a60 100644 --- a/packages/node-logger/src/config.test.ts +++ b/packages/node-logger/src/config.test.ts @@ -1,11 +1,20 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import loadConfiguration from './config.js'; -import type { Config, LogLevel, Output } from './types.js'; +import type { Config } from './types.js'; let root: string; +const zodDir = dirname(createRequire(import.meta.url).resolve('zod/package.json')); + +/** + * Accepts invalid configurations in the tests + */ +function invalid(config: unknown): Config { + return config as Config; +} /** * Creates a project with a package.json and installed formatter and plugin packages (ES modules) @@ -23,6 +32,22 @@ function createProject(name: string, packageJson: Record): stri fs.writeFileSync(join(projectDir, 'package.json'), JSON.stringify(packageJson)); writeModule('test-formatter', 'export default function testFormatter() { return \'formatted\'; }'); writeModule('test-plugin', 'export default class TestPlugin { constructor(config) { this.config = config; } }'); + writeModule('schema-plugin', ` + import { z } from 'zod'; + export default class SchemaPlugin { + static configSchema = z.object({ + schemaPlugin: z.strictObject({ size: z.coerce.number().default(10) }).prefault({}), + }); + constructor(config) { this.config = config; } + } + `); + writeModule('conflicting-plugin', ` + import { z } from 'zod'; + export default class ConflictingPlugin { + static configSchema = z.object({ channel: z.number() }); + } + `); + fs.symlinkSync(zodDir, join(projectDir, 'node_modules', 'zod')); return projectDir; } @@ -44,73 +69,49 @@ describe('loadConfiguration', () => { }); describe('validation', () => { - it('throws an error if formatter is missing', () => { - const expectedError = new Error('Invalid formatter: No formatter found in configuration'); - - expect(() => loadConfiguration()).toThrow(expectedError); - }); - - it('throws an error if levels are null', () => { - const expectedError = new Error('Invalid levels: No levels were configured'); - - expect(() => loadConfiguration({ formatter, levels: null as unknown as LogLevel[] })) - .toThrow(expectedError); - }); - - it('throws an error if levels are an empty array', () => { - const expectedError = new Error('Invalid levels: No levels were configured'); - - expect(() => loadConfiguration({ formatter, levels: [] })) - .toThrow(expectedError); - }); - - it('throws an error if no maxLevel is configured', () => { - const expectedError = new Error('Invalid maxLevel: null'); - - expect(() => loadConfiguration({ formatter, maxLevel: null as unknown as LogLevel })) - .toThrow(expectedError); - }); - - it('throws an error if maxLevel is no valid level', () => { - const expectedError = new Error('Invalid maxLevel: foobar'); - - expect(() => loadConfiguration({ formatter, maxLevel: 'foobar' as LogLevel })) - .toThrow(expectedError); - }); - - it('throws an error if no timerLevel is configured', () => { - const expectedError = new Error('Invalid timerLevel: null'); - - expect(() => loadConfiguration({ formatter, timerLevel: null as unknown as LogLevel })) - .toThrow(expectedError); - }); - - it('throws an error if timerLevel is no valid level', () => { - const expectedError = new Error('Invalid timerLevel: foobar'); - - expect(() => loadConfiguration({ formatter, timerLevel: 'foobar' as LogLevel })) - .toThrow(expectedError); + it.each([ + ['the formatter is missing', { formatter: undefined }, 'formatter'], + ['the formatter has a wrong type', { formatter: 42 }, 'formatter'], + ['the levels are null', { levels: null }, 'levels'], + ['the levels are empty', { levels: [] }, 'levels'], + ['a level is unknown', { levels: ['error', 'fatal'] }, 'levels[1]'], + ['the maxLevel is null', { maxLevel: null }, 'maxLevel'], + ['the maxLevel is no valid level', { maxLevel: 'foobar' }, 'maxLevel'], + ['the maxLevel is not configured as level', { levels: ['error'], timerLevel: 'error' }, 'maxLevel'], + ['the timerLevel is no valid level', { timerLevel: 'foobar' }, 'timerLevel'], + ['the timerLevel is not configured as level', { levels: ['info'], maxLevel: 'info' }, 'timerLevel'], + ['the outputs are null', { outputs: null }, 'outputs'], + ['an output is configured for an unknown level', { outputs: { foobar: 'stdout' } }, 'outputs'], + ['an output is configured for a level that is not configured', { + levels: ['error', 'debug'], + maxLevel: 'error', + outputs: { info: 'stdout' }, + }, 'outputs.info'], + ['an output is anything but stdout or stderr', { outputs: { error: 'stdin' } }, 'outputs.error'], + ['the channel is no string', { channel: 42 }, 'channel'], + ['the plugins are no array', { plugins: 'test-plugin' }, 'plugins'], + ['the context is no object', { context: 'foo' }, 'context'], + ])('throws an error if %s', (_, config, path) => { + expect(() => loadConfiguration(invalid({ formatter, ...config }))) + .toThrow(new RegExp(`^Invalid node-logger configuration:\\n[^]*→ at ${path.replace(/[.[\]]/g, '\\$&')}$`, 'm')); }); - it('throws an error if no outputs were configured', () => { - const expectedError = new Error('Invalid outputs: Outputs can only be configured for existing log levels'); - - expect(() => loadConfiguration({ formatter, outputs: null as unknown as Config['outputs'] })) - .toThrow(expectedError); + it('throws an error for unknown options', () => { + expect(() => loadConfiguration(invalid({ formatter, maxlevel: 'debug' }))) + .toThrow('✖ Unrecognized key: "maxlevel"'); }); - it('throws an error if output was configured for invalid level', () => { - const expectedError = new Error('Invalid outputs: Outputs can only be configured for existing log levels'); - - expect(() => loadConfiguration({ formatter, outputs: { foobar: 'stdout' } as Config['outputs'] })) - .toThrow(expectedError); - }); + it('reports all problems at once', () => { + let message = ''; - it('throws an error if output is anything but stdout or stderr', () => { - const expectedError = new Error('Invalid outputs: Output must be stdout or stderr'); + try { + loadConfiguration(invalid({ formatter, channel: 42, maxLevel: 'foobar' })); + } catch (error) { + message = (error as Error).message; + } - expect(() => loadConfiguration({ formatter, outputs: { error: 'stdin' as Output } })) - .toThrow(expectedError); + expect(message).toContain('→ at channel'); + expect(message).toContain('→ at maxLevel'); }); }); @@ -227,7 +228,7 @@ describe('loadConfiguration', () => { vi.spyOn(process, 'cwd').mockReturnValue(join(root, 'with-plugins')); }); - it('instantiates plugins with the merged configuration', () => { + it('instantiates plugins with the validated configuration', () => { const config = loadConfiguration({ formatter, plugins: ['test-plugin'], @@ -236,12 +237,43 @@ describe('loadConfiguration', () => { expect(config.plugins).toHaveLength(1); expect(config.plugins[0].constructor.name).toBe('TestPlugin'); expect(config.plugins[0]).toHaveProperty('config.channel', 'testpackage'); + expect(config.plugins[0]).toHaveProperty('config.maxLevel', 'info'); }); it('throws an error if a plugin cannot be found', () => { expect(() => loadConfiguration({ formatter, plugins: ['unknown-plugin'] })) .toThrow(/^Plugin unknown-plugin not found in .*node_modules: /); }); + + it('validates the options of plugins and applies their defaults', () => { + const config = loadConfiguration(invalid({ + formatter, + plugins: ['schema-plugin'], + schemaPlugin: { size: 'env(SIZE, 20)' }, + })); + const defaults = loadConfiguration({ formatter, plugins: ['schema-plugin'] }); + + expect(config.plugins[0]).toHaveProperty('config.schemaPlugin', { size: 20 }); + expect(defaults.plugins[0]).toHaveProperty('config.schemaPlugin', { size: 10 }); + }); + + it('throws an error for invalid options of plugins', () => { + expect(() => loadConfiguration(invalid({ + formatter, + plugins: ['schema-plugin'], + schemaPlugin: { size: 'big', color: 'red' }, + }))).toThrow(/Unrecognized key: "color"\n {2}→ at schemaPlugin[^]*→ at schemaPlugin\.size/); + }); + + it('throws an error for options of plugins that are not loaded', () => { + expect(() => loadConfiguration(invalid({ formatter, plugins: ['test-plugin'], schemaPlugin: {} }))) + .toThrow('✖ Unrecognized key: "schemaPlugin"'); + }); + + it('throws an error if a plugin redefines an option', () => { + expect(() => loadConfiguration({ formatter, plugins: ['conflicting-plugin'] })) + .toThrow('Plugin conflicting-plugin must not redefine the option "channel" of node-logger'); + }); }); describe('environment variables', () => { diff --git a/packages/node-logger/src/config.ts b/packages/node-logger/src/config.ts index d5cb63c..7df41c1 100644 --- a/packages/node-logger/src/config.ts +++ b/packages/node-logger/src/config.ts @@ -2,7 +2,9 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import merge from 'deepmerge'; -import type { Config, Formatter, MergedConfig, Plugin, PluginConstructor, ResolvedConfig } from './types.js'; +import { z } from 'zod'; +import { configShape, createConfigSchema } from './schema.js'; +import type { Config, Formatter, PluginConstructor, ResolvedConfig, ValidatedConfig } from './types.js'; type ModuleRequire = ReturnType; @@ -12,32 +14,10 @@ interface PackageConfigDefinition { config: Config; } -const defaultConfig: MergedConfig = { - channel: 'unknown', - levels: [ - 'emergency', - 'alert', - 'critical', - 'error', - 'warning', - 'notice', - 'info', - 'debug', - ], - maxLevel: 'info', - outputs: { - emergency: 'stderr', - warning: 'stdout', - }, - timerLevel: 'debug', - formatter: undefined, - plugins: [], -}; - function getConfigFromPackage(packageJsonPath: string): Config { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string; nodeLogger?: Config }; - return { channel: pkg.name || defaultConfig.channel, ...pkg.nodeLogger }; + return { ...(pkg.name ? { channel: pkg.name } : {}), ...pkg.nodeLogger }; } function findPackageJson(): string | null { @@ -80,7 +60,7 @@ function loadModule(moduleName: string, require: ModuleRequire | null): T { return (require(moduleName) as { default: T }).default; } -function loadFormatter(config: MergedConfig, { path, require }: PackageConfigDefinition): Formatter { +function loadFormatter(config: ValidatedConfig, { path, require }: PackageConfigDefinition): Formatter { const { formatter } = config; if (typeof formatter === 'function') { @@ -92,7 +72,7 @@ function loadFormatter(config: MergedConfig, { path, require }: PackageConfigDef } try { - return loadModule(formatter!, require); + return loadModule(formatter, require); } catch (error) { throw new Error(`Formatter ${formatter} not found in ${path}: ${(error as Error).message}`, { cause: error }); } @@ -106,13 +86,6 @@ function loadPlugin(pluginName: string, { path, require }: PackageConfigDefiniti } } -function loadPlugins(config: MergedConfig, definition: PackageConfigDefinition): Plugin[] { - return (config.plugins ?? []).map((pluginName) => { - const PluginClass = loadPlugin(pluginName, definition); - return new PluginClass(config); - }); -} - function deepParseEnv(config: T): T { if (!config || Array.isArray(config) || typeof config === 'boolean' || typeof config === 'function') { return config; @@ -147,36 +120,44 @@ function deepParseEnv(config: T): T { ) as T; } -function assertConfig(config: MergedConfig): void { - if (!config.formatter) { - throw new Error('Invalid formatter: No formatter found in configuration'); - } +/** + * Collects the options the plugins add to the configuration + */ +function getPluginShape(pluginNames: string[], pluginClasses: PluginConstructor[]): z.ZodRawShape { + const owners = new Map(Object.keys(configShape).map(key => [key, 'node-logger'])); + const shape: Record = {}; - if (!Array.isArray(config.levels) || config.levels.length === 0) { - throw new Error('Invalid levels: No levels were configured'); - } + pluginClasses.forEach((PluginClass, index) => { + Object.entries(PluginClass.configSchema?.shape ?? {}).forEach(([key, schema]) => { + const owner = owners.get(key); - if (!config.maxLevel || !config.levels.includes(config.maxLevel)) { - throw new Error(`Invalid maxLevel: ${config.maxLevel}`); - } + if (owner) { + throw new Error(`Plugin ${pluginNames[index]} must not redefine the option "${key}" of ${owner}`); + } - if (!config.timerLevel || !config.levels.includes(config.timerLevel)) { - throw new Error(`Invalid timerLevel: ${config.timerLevel}`); - } + owners.set(key, pluginNames[index]); + shape[key] = schema; + }); + }); - if (!config.outputs || Object.keys(config.outputs).some(level => !(config.levels as string[]).includes(level))) { - throw new Error('Invalid outputs: Outputs can only be configured for existing log levels'); - } + return shape; +} + +function validate(schema: T, config: unknown): z.output { + const result = schema.safeParse(config); - if (Object.values(config.outputs).some(output => !['stdout', 'stderr'].includes(output))) { - throw new Error('Invalid outputs: Output must be stdout or stderr'); + if (!result.success) { + throw new Error(`Invalid node-logger configuration:\n${z.prettifyError(result.error)}`, { + cause: result.error, + }); } + + return result.data; } export default function loadConfiguration(config: Config = {}): ResolvedConfig { const packageJsonConfigDefinition = loadConfigFromPackage(); - const merged = deepParseEnv(merge.all([ - defaultConfig, + const merged = deepParseEnv(merge.all([ packageJsonConfigDefinition.config, config, ], { @@ -187,11 +168,15 @@ export default function loadConfiguration(config: Config = {}): ResolvedConfig { }, })); - assertConfig(merged); + // The plugins have to be loaded first, as they add their options to the schema + const { plugins: pluginNames } = validate(z.looseObject({ plugins: configShape.plugins }), merged); + const pluginClasses = pluginNames.map(pluginName => loadPlugin(pluginName, packageJsonConfigDefinition)); + const schema = createConfigSchema(getPluginShape(pluginNames, pluginClasses)); + const validated = validate(schema, merged) as ValidatedConfig; return { - ...merged, - formatter: loadFormatter(merged, packageJsonConfigDefinition), - plugins: loadPlugins(merged, packageJsonConfigDefinition), + ...validated, + formatter: loadFormatter(validated, packageJsonConfigDefinition), + plugins: pluginClasses.map(PluginClass => new PluginClass(validated)), }; } diff --git a/packages/node-logger/src/index.ts b/packages/node-logger/src/index.ts index 6dc2ce2..c4d212b 100644 --- a/packages/node-logger/src/index.ts +++ b/packages/node-logger/src/index.ts @@ -1,3 +1,4 @@ export * from './errors/index.js'; export { default as Logger } from './logger.js'; +export { logLevelSchema } from './schema.js'; export type * from './types.js'; diff --git a/packages/node-logger/src/schema.ts b/packages/node-logger/src/schema.ts new file mode 100644 index 0000000..a200bbf --- /dev/null +++ b/packages/node-logger/src/schema.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import type { Formatter } from './types.js'; + +export const logLevelSchema = z.enum([ + 'emergency', + 'alert', + 'critical', + 'error', + 'warning', + 'notice', + 'info', + 'debug', +]); + +const outputSchema = z.enum(['stdout', 'stderr']); + +export const configShape = { + channel: z.string().default('unknown'), + levels: z.array(logLevelSchema).nonempty().default([...logLevelSchema.options]), + maxLevel: logLevelSchema.default('info'), + outputs: z.partialRecord(logLevelSchema, outputSchema).default({ + emergency: 'stderr', + warning: 'stdout', + }), + timerLevel: logLevelSchema.default('debug'), + formatter: z.union([ + z.string().nonempty(), + z.custom(value => typeof value === 'function'), + ], { error: 'Expected the module name of a formatter or a formatter function' }), + plugins: z.array(z.string().nonempty()).default([]), + context: z.looseObject({ + user: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), + }).optional(), +}; + +export type CoreConfigInput = z.input>; +export type CoreConfigOutput = z.output>; + +/** + * Creates the schema for the whole configuration: the core options and the options of all plugins. + * Unknown keys are rejected. + */ +export function createConfigSchema(pluginShape: z.ZodRawShape = {}) { + return z.strictObject({ ...pluginShape, ...configShape }).check((ctx) => { + const { levels, maxLevel, timerLevel, outputs } = ctx.value; + const issue = (path: PropertyKey[], message: string) => { + ctx.issues.push({ code: 'custom', input: ctx.value, path, message }); + }; + + if (!levels.includes(maxLevel)) { + issue(['maxLevel'], `"${maxLevel}" is not one of the configured levels`); + } + + if (!levels.includes(timerLevel)) { + issue(['timerLevel'], `"${timerLevel}" is not one of the configured levels`); + } + + Object.keys(outputs) + .filter(level => !(levels as string[]).includes(level)) + .forEach((level) => { + issue(['outputs', level], `"${level}" is not one of the configured levels`); + }); + }); +} diff --git a/packages/node-logger/src/types.ts b/packages/node-logger/src/types.ts index 93aa31f..37363f4 100644 --- a/packages/node-logger/src/types.ts +++ b/packages/node-logger/src/types.ts @@ -1,4 +1,7 @@ -export type LogLevel = 'emergency' | 'alert' | 'critical' | 'error' | 'warning' | 'notice' | 'info' | 'debug'; +import type { z } from 'zod'; +import type { CoreConfigInput, CoreConfigOutput, logLevelSchema } from './schema.js'; + +export type LogLevel = z.infer; export type Meta = Record; @@ -20,31 +23,27 @@ export interface Plugin { } /** - * Plugins can add their own options by augmenting this interface: + * The configuration as given in the package.json or to the Logger. + * + * This is an interface, so plugins can add their options: * `declare module '@brainbits/node-logger' { interface Config { myPlugin?: MyPluginConfig } }` */ -export interface Config { - channel?: string; - levels?: LogLevel[]; - maxLevel?: LogLevel; - outputs?: Partial>; - timerLevel?: LogLevel; - /** A formatter function or the module name of a formatter */ - formatter?: string | Formatter; - /** Module names of plugins */ - plugins?: string[]; - context?: { - user?: string | Record; - [key: string]: unknown; - }; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Config extends Partial {} + +/** The validated configuration with defaults applied, as passed to plugins */ +export type ValidatedConfig = Config & CoreConfigOutput; + +export interface PluginConstructor { + new (config: ValidatedConfig): Plugin; + /** + * Schema of the options the plugin adds to the configuration, e.g. `z.object({ myPlugin: myPluginSchema })`. + * The logger validates them together with its own options and rejects unknown options. + */ + readonly configSchema?: z.ZodObject; } -/** The configuration merged with the defaults, as passed to plugins */ -export type MergedConfig = Config & Required>; - -export type PluginConstructor = new (config: MergedConfig) => Plugin; - -export interface ResolvedConfig extends Omit { +export interface ResolvedConfig extends Omit { formatter: Formatter; plugins: Plugin[]; } diff --git a/vitest.config.ts b/vitest.config.ts index 2d931df..4fb2b98 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,13 @@ +import { defaultServerConditions } from 'vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + ssr: { + resolve: { + // Resolve workspace packages to their sources, so no build is needed for the tests + conditions: ['@brainbits/source', ...defaultServerConditions], + }, + }, test: { include: ['packages/*/src/**/*.test.ts'], clearMocks: true,