Skip to content

Commit 55df65e

Browse files
authored
feat(logger): format unknown errors (#59)
1 parent e55eea5 commit 55df65e

File tree

3 files changed

+183
-1
lines changed

3 files changed

+183
-1
lines changed

src/__tests__/__snapshots__/logger.test.ts.snap

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ exports[`createLogger should attempt to subscribe and unsubscribe from a channel
3333
}
3434
`;
3535

36+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, bigint 1`] = `"9007199254740991n"`;
37+
38+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, boolean 1`] = `"Non-Error thrown: true"`;
39+
40+
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"}"`;
41+
42+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, function 1`] = `"Non-Error thrown: undefined"`;
43+
44+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, null 1`] = `"Non-Error thrown: null"`;
45+
46+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, number 1`] = `"Non-Error thrown: 10000"`;
47+
48+
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"}"`;
49+
50+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, string 1`] = `"lorem ipsum dolor sit amet"`;
51+
52+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, symbol 1`] = `"Non-Error thrown: undefined"`;
53+
54+
exports[`formatUnknownError should attempt to return a formatted error on non-errors, undefined 1`] = `"Non-Error thrown: undefined"`;
55+
3656
exports[`logSeverity should return log severity, debug 1`] = `0`;
3757

3858
exports[`logSeverity should return log severity, default 1`] = `-1`;
@@ -195,3 +215,15 @@ exports[`subscribeToChannel should attempt to subscribe and unsubscribe from a c
195215
`;
196216

197217
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"`;
218+
219+
exports[`truncate should truncate a string, default 1`] = `"lorem ipsum...[truncated]"`;
220+
221+
exports[`truncate should truncate a string, null 1`] = `null`;
222+
223+
exports[`truncate should truncate a string, number 1`] = `10000`;
224+
225+
exports[`truncate should truncate a string, object string 1`] = `"{"lorem":"i...[truncated]"`;
226+
227+
exports[`truncate should truncate a string, suffix overrides max 1`] = `"...[truncated]"`;
228+
229+
exports[`truncate should truncate a string, undefined 1`] = `undefined`;

src/__tests__/logger.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import diagnostics_channel from 'node:diagnostics_channel';
22
import { setOptions, getLoggerOptions } from '../options.context';
3-
import { logSeverity, publish, subscribeToChannel, registerStderrSubscriber, createLogger } from '../logger';
3+
import { logSeverity, truncate, formatUnknownError, publish, subscribeToChannel, registerStderrSubscriber, createLogger } from '../logger';
44

55
describe('logSeverity', () => {
66
it.each([
@@ -29,6 +29,90 @@ describe('logSeverity', () => {
2929
});
3030
});
3131

32+
describe('truncate', () => {
33+
it.each([
34+
{
35+
description: 'default',
36+
value: 'lorem ipsum dolor sit amet',
37+
max: 25
38+
},
39+
{
40+
description: 'object string',
41+
value: JSON.stringify({ lorem: 'ipsum dolor sit amet' }),
42+
max: 25
43+
},
44+
{
45+
description: 'suffix overrides max',
46+
value: 'lorem',
47+
max: 5
48+
},
49+
{
50+
description: 'number',
51+
value: 10_000,
52+
max: 25
53+
},
54+
{
55+
description: 'undefined',
56+
value: undefined,
57+
max: 25
58+
},
59+
{
60+
description: 'null',
61+
value: null,
62+
max: 25
63+
}
64+
])(`should truncate a string, $description`, ({ value, max }) => {
65+
expect(truncate(value as any, { max })).toMatchSnapshot();
66+
});
67+
});
68+
69+
describe('formatUnknownError', () => {
70+
it.each([
71+
{
72+
description: 'error, non-error',
73+
err: { ...new Error('lorem ipsum dolor sit amet', { cause: 'dolor' }), message: 'lorem ipsum dolor sit amet' }
74+
},
75+
{
76+
description: 'symbol',
77+
err: Symbol('lorem ipsum')
78+
},
79+
{
80+
description: 'function',
81+
err: () => {}
82+
},
83+
{
84+
description: 'boolean',
85+
err: true
86+
},
87+
{
88+
description: 'string',
89+
err: 'lorem ipsum dolor sit amet'
90+
},
91+
{
92+
description: 'object',
93+
err: { lorem: 'ipsum dolor sit amet', dolor: 'sit amet', amet: 'consectetur adipiscing elit' }
94+
},
95+
{
96+
description: 'bigint',
97+
err: BigInt(Number.MAX_SAFE_INTEGER)
98+
},
99+
{
100+
description: 'number',
101+
err: 10_000
102+
},
103+
{
104+
description: 'undefined',
105+
err: undefined
106+
},
107+
{
108+
description: 'null',
109+
err: null
110+
}
111+
])('should attempt to return a formatted error on non-errors, $description', ({ err }) => {
112+
expect(formatUnknownError(err)).toMatchSnapshot();
113+
});
114+
});
115+
32116
describe('publish', () => {
33117
let channelSpy: jest.SpyInstance;
34118
const mockPublish = jest.fn();

src/logger.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { channel, unsubscribe, subscribe } from 'node:diagnostics_channel';
2+
import { inspect } from 'node:util';
23
import { type LoggingSession } from './options.defaults';
34
import { getLoggerOptions } from './options.context';
45

@@ -50,6 +51,69 @@ const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error'];
5051
const logSeverity = (level: unknown): number =>
5152
LOG_LEVELS.indexOf(level as LogLevel);
5253

54+
/**
55+
* Basic string HARD truncate.
56+
*
57+
* - Passing a non-string returns the original value.
58+
* - Suffix length is counted against `max`. If `suffix.length >= max`, only
59+
* `suffix` is returned, which may exceed the set `max`.
60+
*
61+
* @param str
62+
* @param options
63+
* @param options.max
64+
* @param options.suffix - Appended suffix string. Suffix length is counted against max length.
65+
* @returns Truncated string, or the suffix only, or the original string, or the original non-string value.
66+
*/
67+
const truncate = (str: string, { max = 250, suffix = '...[truncated]' }: { max?: number, suffix?: string } = {}) => {
68+
if (typeof str === 'string') {
69+
const updatedMax = Math.max(0, max - suffix.length);
70+
71+
if (updatedMax <= 0) {
72+
return suffix;
73+
}
74+
75+
return str.length > updatedMax ? `${str.slice(0, updatedMax)}${suffix}` : str;
76+
}
77+
78+
return str;
79+
};
80+
81+
/**
82+
* Format an unknown value as a string, for logging.
83+
*
84+
* @param value
85+
* @returns Formatted string
86+
*/
87+
const formatUnknownError = (value: unknown): string => {
88+
if (value instanceof Error) {
89+
const message = value.stack || value.message;
90+
91+
if (message) {
92+
return message;
93+
}
94+
95+
try {
96+
return String(value);
97+
} catch {
98+
return Object.prototype.toString.call(value);
99+
}
100+
}
101+
102+
if (typeof value === 'string') {
103+
return value;
104+
}
105+
106+
try {
107+
return `Non-Error thrown: ${truncate(JSON.stringify(value))}`;
108+
} catch {
109+
try {
110+
return truncate(inspect(value, { depth: 3, maxArrayLength: 50, breakLength: 120 }));
111+
} catch {
112+
return Object.prototype.toString.call(value);
113+
}
114+
}
115+
};
116+
53117
/**
54118
* Publish a structured log event to the diagnostics channel.
55119
*
@@ -215,11 +279,13 @@ const createLogger = (options: LoggingSession = getLoggerOptions()): Unsubscribe
215279
export {
216280
LOG_LEVELS,
217281
createLogger,
282+
formatUnknownError,
218283
log,
219284
logSeverity,
220285
publish,
221286
registerStderrSubscriber,
222287
subscribeToChannel,
288+
truncate,
223289
type LogEvent,
224290
type LogLevel,
225291
type Unsubscribe

0 commit comments

Comments
 (0)