Skip to content

Commit ea3cb57

Browse files
authored
feat: log error causes (without breaking things this time) (#438)
This is the proper reimplementation of #428 & revert of #434 ![image](https://github.com/apify/apify-shared-js/assets/17960496/0a664bed-8775-4ec6-860b-679538e77646) ```ts import log from '@apify/log'; const err = new Error('Some error'); const errWithCause = new Error('Some error with cause', { cause: err }); const errWithNestedCause = new Error('Some error with nested cause', { cause: errWithCause }); log.exception(errWithNestedCause); ```
1 parent 3107dea commit ea3cb57

12 files changed

+243
-74
lines changed

packages/log/src/log.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export class Log {
170170
if (level > this.options.level) return;
171171

172172
data = { ...this.options.data, ...data };
173-
data = Object.keys(data).length > 0 ? this._limitDepth(data) : undefined;
173+
data = Reflect.ownKeys(data).length > 0 ? this._limitDepth(data) : undefined;
174174
exception = this._limitDepth(exception);
175175

176176
this.options.logger.log(level, message, data, exception, {

packages/log/src/log_consts.ts

+6
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ export const LEVELS = LogLevel;
2323

2424
// Inverse of LOG_LEVELS = maps log level to string.
2525
export const LEVEL_TO_STRING = Object.keys(LogLevel).filter((x) => Number.isNaN(+x));
26+
27+
/**
28+
* A symbol used to mark a limited depth object as having come from an error
29+
* @internal
30+
*/
31+
export const IS_APIFY_LOGGER_EXCEPTION = Symbol('apify.processed_error');

packages/log/src/log_helpers.ts

+21-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { APIFY_ENV_VARS } from '@apify/consts';
2-
import { LogLevel, LogFormat } from './log_consts';
2+
import { LogLevel, LogFormat, IS_APIFY_LOGGER_EXCEPTION } from './log_consts';
33

44
/**
55
* Ensures a string is shorter than a specified number of character, and truncates it if not, appending a specific suffix to it.
@@ -64,15 +64,15 @@ export function limitDepth<T>(record: T, depth: number, maxStringLength?: number
6464
return maxStringLength && record.length > maxStringLength ? truncate(record, maxStringLength) as unknown as T : record;
6565
}
6666

67-
if (['number', 'boolean'].includes(typeof record) || record == null || record instanceof Date) {
67+
if (['number', 'boolean', 'symbol', 'bigint'].includes(typeof record) || record == null || record instanceof Date) {
6868
return record;
6969
}
7070

7171
// WORKAROUND: Error's properties are not iterable, convert it to a simple object and preserve custom properties
7272
// NOTE: _.isError() doesn't work on Match.Error
7373
if (record instanceof Error) {
74-
const { name, message, stack, ...rest } = record;
75-
record = { name, message, stack, ...rest } as unknown as T;
74+
const { name, message, stack, cause, ...rest } = record;
75+
record = { name, message, stack, cause, ...rest, [IS_APIFY_LOGGER_EXCEPTION]: true } as unknown as T;
7676
}
7777

7878
const nextCall = (rec: T) => limitDepth(rec, depth - 1, maxStringLength);
@@ -84,7 +84,7 @@ export function limitDepth<T>(record: T, depth: number, maxStringLength?: number
8484
if (typeof record === 'object' && record !== null) {
8585
const mapObject = <U extends Record<PropertyKey, any>> (obj: U) => {
8686
const res = {} as U;
87-
Object.keys(obj).forEach((key: keyof U) => {
87+
Reflect.ownKeys(obj).forEach((key: keyof U) => {
8888
res[key as keyof U] = nextCall(obj[key]) as U[keyof U];
8989
});
9090
return res;
@@ -104,3 +104,19 @@ export function limitDepth<T>(record: T, depth: number, maxStringLength?: number
104104

105105
return undefined;
106106
}
107+
108+
// Like an error class, but turned into an object
109+
export interface LimitedError {
110+
// used to identify this object as an error
111+
[IS_APIFY_LOGGER_EXCEPTION]: true;
112+
name: string;
113+
message: string;
114+
stack?: string;
115+
cause?: unknown;
116+
117+
// Custom properties
118+
type?: string;
119+
details?: Record<string, unknown>;
120+
reason?: string;
121+
[key: string]: unknown;
122+
}

packages/log/src/logger.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* eslint-disable no-console */
22
import { EventEmitter } from 'events';
33
import { LogLevel } from './log_consts';
4-
import { Exception } from './logger_text';
54

65
/**
76
* This is an abstract class that should
@@ -39,7 +38,7 @@ export class Logger extends EventEmitter {
3938
}
4039

4140
// eslint-disable-next-line @typescript-eslint/no-unused-vars
42-
_log(level: LogLevel, message: string, data?: any, exception?: Exception, opts: Record<string, any> = {}) {
41+
_log(level: LogLevel, message: string, data?: any, exception?: unknown, opts: Record<string, any> = {}) {
4342
throw new Error('log() method must be implemented!');
4443
}
4544

packages/log/src/logger_json.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { PREFIX_DELIMITER, LogLevel } from './log_consts';
22
import { Logger } from './logger';
3-
import { Exception } from './logger_text';
43

54
const DEFAULT_OPTIONS = {
65
skipLevelInfo: false,
@@ -12,7 +11,7 @@ export class LoggerJson extends Logger {
1211
super({ ...DEFAULT_OPTIONS, ...options });
1312
}
1413

15-
_log(level: LogLevel, message: string, data?: any, exception?: Exception, opts: Record<string, any> = {}) {
14+
_log(level: LogLevel, message: string, data?: any, exception?: unknown, opts: Record<string, any> = {}) {
1615
const { prefix, suffix } = opts;
1716

1817
if (exception) data = { ...data, exception };

packages/log/src/logger_text.ts

+56-28
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import c from 'ansi-colors';
22
import { Logger } from './logger';
3-
import { LEVEL_TO_STRING, LogLevel, PREFIX_DELIMITER } from './log_consts';
3+
import { IS_APIFY_LOGGER_EXCEPTION, LEVEL_TO_STRING, LogLevel, PREFIX_DELIMITER } from './log_consts';
4+
import { LimitedError } from './log_helpers';
5+
import { getStackFrames } from './node_internals';
46

57
const SHORTEN_LEVELS = {
68
SOFT_FAIL: 'SFAIL',
@@ -31,18 +33,12 @@ const DEFAULT_OPTIONS = {
3133
skipTime: true,
3234
};
3335

34-
export interface Exception extends Error {
35-
type?: string;
36-
details?: Record<string, any>;
37-
reason?: string;
38-
}
39-
4036
export class LoggerText extends Logger {
4137
constructor(options = {}) {
4238
super({ ...DEFAULT_OPTIONS, ...options });
4339
}
4440

45-
_log(level: LogLevel, message: string, data?: any, exception?: Exception, opts: Record<string, any> = {}) {
41+
_log(level: LogLevel, message: string, data?: any, exception?: unknown, opts: Record<string, any> = {}) {
4642
let { prefix, suffix } = opts;
4743

4844
let maybeDate = '';
@@ -65,35 +61,67 @@ export class LoggerText extends Logger {
6561
return line;
6662
}
6763

68-
_parseException(exception: Exception) {
69-
let errStack = '';
64+
protected _parseException(exception: unknown, indentLevel = 1) {
65+
if (['string', 'boolean', 'number', 'undefined', 'bigint'].includes(typeof exception)) {
66+
return `\n${exception}`;
67+
}
68+
69+
if (exception === null) {
70+
return '\nnull';
71+
}
72+
73+
// We need to manually call toString on symbols
74+
if (typeof exception === 'symbol') {
75+
return `\n${exception.toString()}`;
76+
}
77+
78+
if (typeof exception === 'object' && IS_APIFY_LOGGER_EXCEPTION in exception) {
79+
return this._parseLoggerException(exception as LimitedError, indentLevel);
80+
}
81+
82+
// Anything else we just stringify
83+
return `\n${JSON.stringify(exception, null, 2)}`;
84+
}
7085

71-
// Parse error.type and error.details from ApifyClientError.
86+
private _parseLoggerException(exception: LimitedError, indentLevel = 1) {
7287
const errDetails = [];
73-
if (exception.type) errDetails.push(`type=${exception.type}`);
88+
89+
if (exception.type) {
90+
errDetails.push(`type=${exception.type}`);
91+
}
92+
7493
if (exception.details) {
7594
Object.entries(exception.details).map(([key, val]) => errDetails.push(`${key}=${val}`));
7695
}
7796

78-
// Parse error stack lines.
79-
// NOTE: Reason is here to support Meteor.js like errors.
80-
const errorString = exception.stack || exception.reason || exception.toString();
81-
const errorLines = errorString.split('\n');
82-
// const causeString = exception.cause
83-
// ? inspect(exception.cause, { colors: true, maxArrayLength: 20 }) : null;
97+
// Parse the stack lines
98+
const errorString = exception.stack || exception.reason || exception.message;
99+
const isStack = errorString === exception.stack;
100+
const errorLines = getStackFrames(exception, errorString);
101+
102+
if (isStack) {
103+
// Remove the useless `Error` prefix from stack traces
104+
errorLines[0] = exception.message || errorLines[0];
105+
}
106+
107+
// Add details to the first line.
108+
if (errDetails.length) {
109+
errorLines[0] += c.gray(`(details: ${errDetails.join(', ')})`);
110+
}
84111

85-
// Add details to a first line.
86-
if (errDetails.length) errorLines[0] += c.gray(`(details: ${errDetails.join(', ')})`);
112+
// Make stack lines gray
113+
for (let i = 1; i < errorLines.length; i++) {
114+
errorLines[i] = c.gray(errorLines[i]);
115+
}
87116

88-
// if (causeString) {
89-
// const causeLines = causeString.split('\n');
90-
// errorLines.push(c.gray(`Cause: ${causeLines[0]}`), ...causeLines.slice(1).map((line) => ` ${line}`));
91-
// }
117+
// Recursively parse the cause.
118+
if (exception.cause) {
119+
const causeString = this._parseException(exception.cause, indentLevel + 1);
120+
const causeLines = causeString.trim().split('\n');
92121

93-
// Compose it back.
94-
errStack = errorLines.map((line) => ` ${line}`).join('\n');
95-
errStack = `\n${errStack}`;
122+
errorLines.push(c.red(` CAUSE: ${c.reset(causeLines[0])}`), ...causeLines.slice(1));
123+
}
96124

97-
return errStack;
125+
return `\n${errorLines.map((line) => `${' '.repeat(indentLevel * 2)}${line}`).join('\n')}`;
98126
}
99127
}

packages/log/src/node_internals.ts

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
THE FOLLOWING CODE IS LICENSED UNDER THE FOLLOWING LICENSE:
3+
4+
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to
7+
deal in the Software without restriction, including without limitation the
8+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9+
sell copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21+
IN THE SOFTWARE.
22+
*/
23+
24+
// We've adapted the following code to work with our "error" representations (which are just nested simple objects)
25+
26+
import c from 'ansi-colors';
27+
import { IS_APIFY_LOGGER_EXCEPTION } from './log_consts';
28+
29+
function identicalSequenceRange(a: any[], b: any[]) {
30+
for (let i = 0; i < a.length - 3; i++) {
31+
// Find the first entry of b that matches the current entry of a.
32+
const pos = b.indexOf(a[i]);
33+
if (pos !== -1) {
34+
const rest = b.length - pos;
35+
if (rest > 3) {
36+
let len = 1;
37+
const maxLen = Math.min(a.length - i, rest);
38+
// Count the number of consecutive entries.
39+
while (maxLen > len && a[i + len] === b[pos + len]) {
40+
len++;
41+
}
42+
if (len > 3) {
43+
return { len, offset: i };
44+
}
45+
}
46+
}
47+
}
48+
49+
return { len: 0, offset: 0 };
50+
}
51+
52+
function getStackString(error: any) {
53+
return error.stack ? String(error.stack) : Error.prototype.toString.call(error);
54+
}
55+
56+
export function getStackFrames(err: Error, stack: string) {
57+
const frames = stack.split('\n');
58+
59+
let cause;
60+
try {
61+
({ cause } = err);
62+
} catch {
63+
// If 'cause' is a getter that throws, ignore it.
64+
}
65+
66+
// Remove stack frames identical to frames in cause.
67+
if (cause != null && typeof cause === 'object' && IS_APIFY_LOGGER_EXCEPTION in (cause as any)) {
68+
const causeStack = getStackString(cause);
69+
const causeStackStart = causeStack.indexOf('\n at');
70+
if (causeStackStart !== -1) {
71+
const causeFrames = causeStack.slice(causeStackStart + 1).split('\n');
72+
const { len, offset } = identicalSequenceRange(frames, causeFrames);
73+
if (len > 0) {
74+
const skipped = len - 2;
75+
const msg = ` ... ${skipped} lines matching cause stack trace ...`;
76+
frames.splice(offset + 1, skipped, c.grey(msg));
77+
}
78+
}
79+
}
80+
return frames;
81+
}

packages/utilities/src/exponential_backoff.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import log, { Exception } from '@apify/log';
1+
import log from '@apify/log';
22
import { delayPromise } from './utilities';
33

44
export class RetryableError extends Error {
5-
readonly error: Exception;
5+
readonly error: Error;
66

7-
constructor(error: Error | Exception, ...args: unknown[]) {
7+
constructor(error: Error, ...args: unknown[]) {
88
super(...args as [string]);
9-
this.error = error as Exception;
9+
this.error = error;
1010
}
1111
}
1212

1313
// extend the error with added properties
14-
export interface RetryableError extends Exception {}
14+
export interface RetryableError extends Error {}
1515

1616
export async function retryWithExpBackoff<T>(
1717
params: { func?: (...args: unknown[]) => T, expBackoffMillis?: number, expBackoffMaxRepeats?: number } = {},
@@ -54,7 +54,7 @@ export async function retryWithExpBackoff<T>(
5454
if (i === Math.round(expBackoffMaxRepeats / 2)) {
5555
log.warning(`Retry failed ${i} times and will be repeated in ${randomizedWaitMillis}ms`, {
5656
originalError: error.error.message,
57-
errorDetails: error.error.details,
57+
errorDetails: Reflect.get(error.error, 'details'),
5858
});
5959
}
6060

test/exponential_backoff.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import log, { Exception } from '@apify/log';
1+
import log from '@apify/log';
22
import { retryWithExpBackoff, RetryableError } from '@apify/utilities';
33

44
describe('exponential_backoff', () => {
@@ -103,19 +103,19 @@ describe('exponential_backoff', () => {
103103
it('should display correct message after 1/2 of retries', async () => {
104104
const logWarningSpy = jest.spyOn(log, 'warning');
105105

106-
let error!: Exception;
106+
let error!: Error & { details?: Record<string, any> };
107107
try {
108108
await retryWithExpBackoff({
109109
func: async () => {
110-
const err = new Error('Failed because of XXX') as Exception;
110+
const err = new Error('Failed because of XXX') as Error & { details?: Record<string, any> };
111111
err.details = { foo: 'bar' };
112112
throw new RetryableError(err);
113113
},
114114
expBackoffMaxRepeats: 6,
115115
expBackoffMillis: 10,
116116
});
117117
} catch (e) {
118-
error = e as Exception;
118+
error = e as Error & { details?: Record<string, any> };
119119
}
120120
expect(error.message).toBe('Failed because of XXX');
121121
expect(error.details).toEqual({ foo: 'bar' });

0 commit comments

Comments
 (0)