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,