diff --git a/src/__tests__/__snapshots__/logger.test.ts.snap b/src/__tests__/__snapshots__/logger.test.ts.snap index 13ddf06..cb0c9e7 100644 --- a/src/__tests__/__snapshots__/logger.test.ts.snap +++ b/src/__tests__/__snapshots__/logger.test.ts.snap @@ -33,6 +33,26 @@ exports[`createLogger should attempt to subscribe and unsubscribe from a channel } `; +exports[`formatUnknownError should attempt to return a formatted error on non-errors, bigint 1`] = `"9007199254740991n"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, boolean 1`] = `"Non-Error thrown: true"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, error, non-error 1`] = `"Non-Error thrown: {"message":"lorem ipsum dolor sit amet"}"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, function 1`] = `"Non-Error thrown: undefined"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, null 1`] = `"Non-Error thrown: null"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, number 1`] = `"Non-Error thrown: 10000"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, object 1`] = `"Non-Error thrown: {"lorem":"ipsum dolor sit amet","dolor":"sit amet","amet":"consectetur adipiscing elit"}"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, string 1`] = `"lorem ipsum dolor sit amet"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, symbol 1`] = `"Non-Error thrown: undefined"`; + +exports[`formatUnknownError should attempt to return a formatted error on non-errors, undefined 1`] = `"Non-Error thrown: undefined"`; + exports[`logSeverity should return log severity, debug 1`] = `0`; exports[`logSeverity should return log severity, default 1`] = `-1`; @@ -195,3 +215,15 @@ exports[`subscribeToChannel should attempt to subscribe and unsubscribe from a c `; exports[`subscribeToChannel should throw an error attempting to subscribe and unsubscribe from a channel: missing channel name 1`] = `"subscribeToChannel called without a configured logging channelName"`; + +exports[`truncate should truncate a string, default 1`] = `"lorem ipsum...[truncated]"`; + +exports[`truncate should truncate a string, null 1`] = `null`; + +exports[`truncate should truncate a string, number 1`] = `10000`; + +exports[`truncate should truncate a string, object string 1`] = `"{"lorem":"i...[truncated]"`; + +exports[`truncate should truncate a string, suffix overrides max 1`] = `"...[truncated]"`; + +exports[`truncate should truncate a string, undefined 1`] = `undefined`; diff --git a/src/__tests__/logger.test.ts b/src/__tests__/logger.test.ts index ec43e01..e380be9 100644 --- a/src/__tests__/logger.test.ts +++ b/src/__tests__/logger.test.ts @@ -1,6 +1,6 @@ import diagnostics_channel from 'node:diagnostics_channel'; import { setOptions, getLoggerOptions } from '../options.context'; -import { logSeverity, publish, subscribeToChannel, registerStderrSubscriber, createLogger } from '../logger'; +import { logSeverity, truncate, formatUnknownError, publish, subscribeToChannel, registerStderrSubscriber, createLogger } from '../logger'; describe('logSeverity', () => { it.each([ @@ -29,6 +29,90 @@ describe('logSeverity', () => { }); }); +describe('truncate', () => { + it.each([ + { + description: 'default', + value: 'lorem ipsum dolor sit amet', + max: 25 + }, + { + description: 'object string', + value: JSON.stringify({ lorem: 'ipsum dolor sit amet' }), + max: 25 + }, + { + description: 'suffix overrides max', + value: 'lorem', + max: 5 + }, + { + description: 'number', + value: 10_000, + max: 25 + }, + { + description: 'undefined', + value: undefined, + max: 25 + }, + { + description: 'null', + value: null, + max: 25 + } + ])(`should truncate a string, $description`, ({ value, max }) => { + expect(truncate(value as any, { max })).toMatchSnapshot(); + }); +}); + +describe('formatUnknownError', () => { + it.each([ + { + description: 'error, non-error', + err: { ...new Error('lorem ipsum dolor sit amet', { cause: 'dolor' }), message: 'lorem ipsum dolor sit amet' } + }, + { + description: 'symbol', + err: Symbol('lorem ipsum') + }, + { + description: 'function', + err: () => {} + }, + { + description: 'boolean', + err: true + }, + { + description: 'string', + err: 'lorem ipsum dolor sit amet' + }, + { + description: 'object', + err: { lorem: 'ipsum dolor sit amet', dolor: 'sit amet', amet: 'consectetur adipiscing elit' } + }, + { + description: 'bigint', + err: BigInt(Number.MAX_SAFE_INTEGER) + }, + { + description: 'number', + err: 10_000 + }, + { + description: 'undefined', + err: undefined + }, + { + description: 'null', + err: null + } + ])('should attempt to return a formatted error on non-errors, $description', ({ err }) => { + expect(formatUnknownError(err)).toMatchSnapshot(); + }); +}); + describe('publish', () => { let channelSpy: jest.SpyInstance; const mockPublish = jest.fn(); diff --git a/src/logger.ts b/src/logger.ts index db659a6..c25dd9d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,5 @@ import { channel, unsubscribe, subscribe } from 'node:diagnostics_channel'; +import { inspect } from 'node:util'; import { type LoggingSession } from './options.defaults'; import { getLoggerOptions } from './options.context'; @@ -50,6 +51,69 @@ const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; const logSeverity = (level: unknown): number => LOG_LEVELS.indexOf(level as LogLevel); +/** + * Basic string HARD truncate. + * + * - Passing a non-string returns the original value. + * - Suffix length is counted against `max`. If `suffix.length >= max`, only + * `suffix` is returned, which may exceed the set `max`. + * + * @param str + * @param options + * @param options.max + * @param options.suffix - Appended suffix string. Suffix length is counted against max length. + * @returns Truncated string, or the suffix only, or the original string, or the original non-string value. + */ +const truncate = (str: string, { max = 250, suffix = '...[truncated]' }: { max?: number, suffix?: string } = {}) => { + if (typeof str === 'string') { + const updatedMax = Math.max(0, max - suffix.length); + + if (updatedMax <= 0) { + return suffix; + } + + return str.length > updatedMax ? `${str.slice(0, updatedMax)}${suffix}` : str; + } + + return str; +}; + +/** + * Format an unknown value as a string, for logging. + * + * @param value + * @returns Formatted string + */ +const formatUnknownError = (value: unknown): string => { + if (value instanceof Error) { + const message = value.stack || value.message; + + if (message) { + return message; + } + + try { + return String(value); + } catch { + return Object.prototype.toString.call(value); + } + } + + if (typeof value === 'string') { + return value; + } + + try { + return `Non-Error thrown: ${truncate(JSON.stringify(value))}`; + } catch { + try { + return truncate(inspect(value, { depth: 3, maxArrayLength: 50, breakLength: 120 })); + } catch { + return Object.prototype.toString.call(value); + } + } +}; + /** * Publish a structured log event to the diagnostics channel. * @@ -215,11 +279,13 @@ const createLogger = (options: LoggingSession = getLoggerOptions()): Unsubscribe export { LOG_LEVELS, createLogger, + formatUnknownError, log, logSeverity, publish, registerStderrSubscriber, subscribeToChannel, + truncate, type LogEvent, type LogLevel, type Unsubscribe