|
1 | 1 | import { APIFY_ENV_VARS } from '@apify/consts'; |
2 | 2 |
|
3 | | -import { IS_APIFY_LOGGER_EXCEPTION, LogFormat, LogLevel } from './log_consts'; |
| 3 | +import { |
| 4 | + IS_APIFY_LOGGER_EXCEPTION, |
| 5 | + LogFormat, |
| 6 | + LogLevel, |
| 7 | + TRUNCATION_FLAG_KEY, |
| 8 | + TRUNCATION_SUFFIX, |
| 9 | +} from './log_consts'; |
4 | 10 |
|
5 | 11 | /** |
6 | 12 | * Ensures a string is shorter than a specified number of character, and truncates it if not, appending a specific suffix to it. |
7 | 13 | * (copied from utilities package so logger do not have to depend on all of its dependencies) |
8 | 14 | */ |
9 | | -export function truncate(str: string, maxLength: number, suffix = '...[truncated]'): string { |
| 15 | +export function truncate(str: string, maxLength: number, suffix = TRUNCATION_SUFFIX): string { |
10 | 16 | maxLength = Math.floor(maxLength); |
11 | 17 |
|
12 | 18 | // TODO: we should just ignore rest of the suffix... |
@@ -53,55 +59,115 @@ export function getFormatFromEnv(): LogFormat { |
53 | 59 | } |
54 | 60 | } |
55 | 61 |
|
| 62 | +type SanitizeDataOptions = { |
| 63 | + maxDepth?: number; |
| 64 | + gradualLimitFactor?: number; |
| 65 | + maxStringLength?: number; |
| 66 | + maxArrayLength?: number; |
| 67 | + maxFields?: number; |
| 68 | + preferredFieldsMap?: Record<PropertyKey, number>; |
| 69 | + truncationSuffix?: string; |
| 70 | + truncationFlagKey?: string; |
| 71 | +}; |
| 72 | + |
56 | 73 | /** |
57 | | - * Limits given object to given depth and escapes function with [function] string. |
| 74 | + * Sanitizes given object based on the given options. |
58 | 75 | * |
59 | 76 | * ie. Replaces object's content by '[object]' and array's content |
60 | | - * by '[array]' when the value is nested more than given limit. |
| 77 | + * by '[array]' when the value is nested more than given depth limit. |
61 | 78 | */ |
62 | | -export function limitDepth<T>(record: T, depth: number, maxStringLength?: number): T | undefined { |
| 79 | +export function sanitizeData(data: unknown, options: SanitizeDataOptions): unknown { |
| 80 | + const { |
| 81 | + maxDepth = Infinity, |
| 82 | + gradualLimitFactor = 1, |
| 83 | + maxStringLength = Infinity, |
| 84 | + maxArrayLength = Infinity, |
| 85 | + maxFields = Infinity, |
| 86 | + preferredFieldsMap = {}, |
| 87 | + truncationSuffix = TRUNCATION_SUFFIX, |
| 88 | + truncationFlagKey = TRUNCATION_FLAG_KEY, |
| 89 | + } = options; |
| 90 | + |
63 | 91 | // handle common cases quickly |
64 | | - if (typeof record === 'string') { |
65 | | - return maxStringLength && record.length > maxStringLength ? truncate(record, maxStringLength) as unknown as T : record; |
| 92 | + if (typeof data === 'string') { |
| 93 | + return data.length > maxStringLength |
| 94 | + ? truncate(data, maxStringLength, truncationSuffix) |
| 95 | + : data; |
66 | 96 | } |
67 | 97 |
|
68 | | - if (['number', 'boolean', 'symbol', 'bigint'].includes(typeof record) || record == null || record instanceof Date) { |
69 | | - return record; |
| 98 | + if (['number', 'boolean', 'symbol', 'bigint'].includes(typeof data) || data == null || data instanceof Date) { |
| 99 | + return data; |
70 | 100 | } |
71 | 101 |
|
72 | 102 | // WORKAROUND: Error's properties are not iterable, convert it to a simple object and preserve custom properties |
73 | 103 | // NOTE: _.isError() doesn't work on Match.Error |
74 | | - if (record instanceof Error) { |
75 | | - const { name, message, stack, cause, ...rest } = record; |
76 | | - record = { name, message, stack, cause, ...rest, [IS_APIFY_LOGGER_EXCEPTION]: true } as unknown as T; |
| 104 | + if (data instanceof Error) { |
| 105 | + const { name, message, stack, cause, ...rest } = data; |
| 106 | + data = { name, message, stack, cause, ...rest, [IS_APIFY_LOGGER_EXCEPTION]: true }; |
77 | 107 | } |
78 | 108 |
|
79 | | - const nextCall = (rec: T) => limitDepth(rec, depth - 1, maxStringLength); |
80 | | - |
81 | | - if (Array.isArray(record)) { |
82 | | - return (depth ? record.map(nextCall) : '[array]') as unknown as T; |
| 109 | + const nextCall = (dat: unknown) => sanitizeData( |
| 110 | + dat, |
| 111 | + { |
| 112 | + ...options, |
| 113 | + maxDepth: maxDepth - 1, |
| 114 | + maxStringLength: Math.max( |
| 115 | + Math.floor(maxStringLength * gradualLimitFactor), |
| 116 | + truncationSuffix.length, // always at least the length of the truncation suffix |
| 117 | + ), |
| 118 | + maxArrayLength: Math.floor(maxArrayLength * gradualLimitFactor), |
| 119 | + maxFields: Math.floor(maxFields * gradualLimitFactor), |
| 120 | + }, |
| 121 | + ); |
| 122 | + |
| 123 | + if (Array.isArray(data)) { |
| 124 | + if (maxDepth <= 0) return '[array]'; |
| 125 | + |
| 126 | + const sanitized = data.slice(0, maxArrayLength).map(nextCall); |
| 127 | + |
| 128 | + if (data.length > maxArrayLength) { |
| 129 | + sanitized.push(truncationSuffix); |
| 130 | + } |
| 131 | + |
| 132 | + return sanitized; |
83 | 133 | } |
84 | 134 |
|
85 | | - if (typeof record === 'object' && record !== null) { |
86 | | - const mapObject = <U extends Record<PropertyKey, any>> (obj: U) => { |
87 | | - const res = {} as U; |
88 | | - Reflect.ownKeys(obj).forEach((key: keyof U) => { |
89 | | - res[key as keyof U] = nextCall(obj[key]) as U[keyof U]; |
90 | | - }); |
91 | | - return res; |
92 | | - }; |
| 135 | + if (typeof data === 'object' && data !== null) { |
| 136 | + if (maxDepth <= 0) return '[object]'; |
| 137 | + |
| 138 | + // Sort preferred fields to the front |
| 139 | + const allKeys = Reflect.ownKeys(data); |
| 140 | + allKeys.sort((a, b) => { |
| 141 | + const aIndex = preferredFieldsMap[String(a)] ?? -1; |
| 142 | + const bIndex = preferredFieldsMap[String(b)] ?? -1; |
| 143 | + |
| 144 | + if (aIndex === -1 && bIndex === -1) return 0; // none is preferred |
| 145 | + if (aIndex === -1) return 1; // a is not preferred |
| 146 | + if (bIndex === -1) return -1; // b is not preferred |
| 147 | + return aIndex - bIndex; // both are preferred, sort by index |
| 148 | + }); |
| 149 | + |
| 150 | + // Sanitize only up to maxFields fields (keeping preferred ones first) |
| 151 | + const sanitized: Record<PropertyKey, unknown> = {}; |
| 152 | + allKeys |
| 153 | + .slice(0, maxFields) |
| 154 | + .forEach((key) => { sanitized[key] = nextCall(data[key as keyof typeof data]); }); |
| 155 | + |
| 156 | + if (allKeys.length > maxFields) { |
| 157 | + sanitized[truncationFlagKey] = true; |
| 158 | + } |
93 | 159 |
|
94 | | - return depth ? mapObject(record) : '[object]' as unknown as T; |
| 160 | + return sanitized; |
95 | 161 | } |
96 | 162 |
|
97 | 163 | // Replaces all function with [function] string |
98 | | - if (typeof record === 'function') { |
99 | | - return '[function]' as unknown as T; |
| 164 | + if (typeof data === 'function') { |
| 165 | + return '[function]'; |
100 | 166 | } |
101 | 167 |
|
102 | 168 | // this shouldn't happen |
103 | 169 | // eslint-disable-next-line no-console |
104 | | - console.log(`WARNING: Object cannot be logged: ${record}`); |
| 170 | + console.log(`WARNING: Object cannot be logged: ${data}`); |
105 | 171 |
|
106 | 172 | return undefined; |
107 | 173 | } |
|
0 commit comments