@@ -5,33 +5,45 @@ import { truncate } from '../utils-hoist/string';
|
5 | 5 |
|
6 | 6 | interface ZodErrorsOptions {
|
7 | 7 | key?: string;
|
| 8 | +/** |
| 9 | +* Limits the number of Zod errors inlined in each Sentry event. |
| 10 | +* |
| 11 | +* @default 10 |
| 12 | +*/ |
8 | 13 | limit?: number;
|
| 14 | +/** |
| 15 | +* Save full list of Zod issues as an attachment in Sentry |
| 16 | +* |
| 17 | +* @default false |
| 18 | +*/ |
| 19 | +saveZodIssuesAsAttachment?: boolean; |
9 | 20 | }
|
10 | 21 |
|
11 | 22 | const DEFAULT_LIMIT = 10;
|
12 | 23 | const INTEGRATION_NAME = 'ZodErrors';
|
13 | 24 |
|
14 |
| -// Simplified ZodIssue type definition |
| 25 | +/** |
| 26 | +* Simplified ZodIssue type definition |
| 27 | +*/ |
15 | 28 | interface ZodIssue {
|
16 | 29 | path: (string | number)[];
|
17 | 30 | message?: string;
|
18 |
| -expected?: string | number; |
19 |
| -received?: string | number; |
| 31 | +expected?: unknown; |
| 32 | +received?: unknown; |
20 | 33 | unionErrors?: unknown[];
|
21 | 34 | keys?: unknown[];
|
| 35 | +invalid_literal?: unknown; |
22 | 36 | }
|
23 | 37 |
|
24 | 38 | interface ZodError extends Error {
|
25 | 39 | issues: ZodIssue[];
|
26 |
| - |
27 |
| -get errors(): ZodError['issues']; |
28 | 40 | }
|
29 | 41 |
|
30 | 42 | function originalExceptionIsZodError(originalException: unknown): originalException is ZodError {
|
31 | 43 | return (
|
32 | 44 | isError(originalException) &&
|
33 | 45 | originalException.name === 'ZodError' &&
|
34 |
| -Array.isArray((originalException as ZodError).errors) |
| 46 | +Array.isArray((originalException as ZodError).issues) |
35 | 47 | );
|
36 | 48 | }
|
37 | 49 |
|
@@ -45,9 +57,18 @@ type SingleLevelZodIssue<T extends ZodIssue> = {
|
45 | 57 |
|
46 | 58 | /**
|
47 | 59 | * Formats child objects or arrays to a string
|
48 |
| -* That is preserved when sent to Sentry |
| 60 | +* that is preserved when sent to Sentry. |
| 61 | +* |
| 62 | +* Without this, we end up with something like this in Sentry: |
| 63 | +* |
| 64 | +* [ |
| 65 | +* [Object], |
| 66 | +* [Object], |
| 67 | +* [Object], |
| 68 | +* [Object] |
| 69 | +* ] |
49 | 70 | */
|
50 |
| -function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> { |
| 71 | +export function flattenIssue(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> { |
51 | 72 | return {
|
52 | 73 | ...issue,
|
53 | 74 | path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined,
|
@@ -56,64 +77,145 @@ function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> {
|
56 | 77 | };
|
57 | 78 | }
|
58 | 79 |
|
| 80 | +/** |
| 81 | +* Takes ZodError issue path array and returns a flattened version as a string. |
| 82 | +* This makes it easier to display paths within a Sentry error message. |
| 83 | +* |
| 84 | +* Array indexes are normalized to reduce duplicate entries |
| 85 | +* |
| 86 | +* @param path ZodError issue path |
| 87 | +* @returns flattened path |
| 88 | +* |
| 89 | +* @example |
| 90 | +* flattenIssuePath([0, 'foo', 1, 'bar']) // -> '<array>.foo.<array>.bar' |
| 91 | +*/ |
| 92 | +export function flattenIssuePath(path: Array<string | number>): string { |
| 93 | +return path |
| 94 | +.map(p => { |
| 95 | +if (typeof p === 'number') { |
| 96 | +return '<array>'; |
| 97 | +} else { |
| 98 | +return p; |
| 99 | +} |
| 100 | +}) |
| 101 | +.join('.'); |
| 102 | +} |
| 103 | + |
59 | 104 | /**
|
60 | 105 | * Zod error message is a stringified version of ZodError.issues
|
61 | 106 | * This doesn't display well in the Sentry UI. Replace it with something shorter.
|
62 | 107 | */
|
63 |
| -function formatIssueMessage(zodError: ZodError): string { |
| 108 | +export function formatIssueMessage(zodError: ZodError): string { |
64 | 109 | const errorKeyMap = new Set<string | number | symbol>();
|
65 | 110 | for (const iss of zodError.issues) {
|
66 |
| -if (iss.path?.[0]) { |
67 |
| -errorKeyMap.add(iss.path[0]); |
| 111 | +const issuePath = flattenIssuePath(iss.path); |
| 112 | +if (issuePath.length > 0) { |
| 113 | +errorKeyMap.add(issuePath); |
68 | 114 | }
|
69 | 115 | }
|
70 |
| -const errorKeys = Array.from(errorKeyMap); |
71 | 116 |
|
| 117 | +const errorKeys = Array.from(errorKeyMap); |
| 118 | +if (errorKeys.length === 0) { |
| 119 | +// If there are no keys, then we're likely validating the root |
| 120 | +// variable rather than a key within an object. This attempts |
| 121 | +// to extract what type it was that failed to validate. |
| 122 | +// For example, z.string().parse(123) would return "string" here. |
| 123 | +let rootExpectedType = 'variable'; |
| 124 | +if (zodError.issues.length > 0) { |
| 125 | +const iss = zodError.issues[0]; |
| 126 | +if (iss !== undefined && 'expected' in iss && typeof iss.expected === 'string') { |
| 127 | +rootExpectedType = iss.expected; |
| 128 | +} |
| 129 | +} |
| 130 | +return `Failed to validate ${rootExpectedType}`; |
| 131 | +} |
72 | 132 | return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`;
|
73 | 133 | }
|
74 | 134 |
|
75 | 135 | /**
|
76 |
| -* Applies ZodError issues to an event extras and replaces the error message |
| 136 | +* Applies ZodError issues to an event extra and replaces the error message |
77 | 137 | */
|
78 |
| -export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventHint): Event { |
| 138 | +export function applyZodErrorsToEvent( |
| 139 | +limit: number, |
| 140 | +saveZodIssuesAsAttachment: boolean = false, |
| 141 | +event: Event, |
| 142 | +hint: EventHint, |
| 143 | +): Event { |
79 | 144 | if (
|
80 | 145 | !event.exception?.values ||
|
81 |
| -!hint?.originalException || |
| 146 | +!hint.originalException || |
82 | 147 | !originalExceptionIsZodError(hint.originalException) ||
|
83 | 148 | hint.originalException.issues.length === 0
|
84 | 149 | ) {
|
85 | 150 | return event;
|
86 | 151 | }
|
87 | 152 |
|
88 |
| -return { |
89 |
| -...event, |
90 |
| -exception: { |
91 |
| -...event.exception, |
92 |
| -values: [ |
93 |
| -{ |
94 |
| -...event.exception.values[0], |
95 |
| -value: formatIssueMessage(hint.originalException), |
| 153 | +try { |
| 154 | +const issuesToFlatten = saveZodIssuesAsAttachment |
| 155 | +? hint.originalException.issues |
| 156 | +: hint.originalException.issues.slice(0, limit); |
| 157 | +const flattenedIssues = issuesToFlatten.map(flattenIssue); |
| 158 | + |
| 159 | +if (saveZodIssuesAsAttachment) { |
| 160 | +// Sometimes having the full error details can be helpful. |
| 161 | +// Attachments have much higher limits, so we can include the full list of issues. |
| 162 | +if (!Array.isArray(hint.attachments)) { |
| 163 | +hint.attachments = []; |
| 164 | +} |
| 165 | +hint.attachments.push({ |
| 166 | +filename: 'zod_issues.json', |
| 167 | +data: JSON.stringify({ |
| 168 | +issues: flattenedIssues, |
| 169 | +}), |
| 170 | +}); |
| 171 | +} |
| 172 | + |
| 173 | +return { |
| 174 | +...event, |
| 175 | +exception: { |
| 176 | +...event.exception, |
| 177 | +values: [ |
| 178 | +{ |
| 179 | +...event.exception.values[0], |
| 180 | +value: formatIssueMessage(hint.originalException), |
| 181 | +}, |
| 182 | +...event.exception.values.slice(1), |
| 183 | +], |
| 184 | +}, |
| 185 | +extra: { |
| 186 | +...event.extra, |
| 187 | +'zoderror.issues': flattenedIssues.slice(0, limit), |
| 188 | +}, |
| 189 | +}; |
| 190 | +} catch (e) { |
| 191 | +// Hopefully we never throw errors here, but record it |
| 192 | +// with the event just in case. |
| 193 | +return { |
| 194 | +...event, |
| 195 | +extra: { |
| 196 | +...event.extra, |
| 197 | +'zoderrors sentry integration parse error': { |
| 198 | +message: 'an exception was thrown while processing ZodError within applyZodErrorsToEvent()', |
| 199 | +error: e instanceof Error ? `${e.name}: ${e.message}\n${e.stack}` : 'unknown', |
96 | 200 | },
|
97 |
| -...event.exception.values.slice(1), |
98 |
| -], |
99 |
| -}, |
100 |
| -extra: { |
101 |
| -...event.extra, |
102 |
| -'zoderror.issues': hint.originalException.errors.slice(0, limit).map(formatIssueTitle), |
103 |
| -}, |
104 |
| -}; |
| 201 | +}, |
| 202 | +}; |
| 203 | +} |
105 | 204 | }
|
106 | 205 |
|
107 | 206 | const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => {
|
108 |
| -const limit = options.limit || DEFAULT_LIMIT; |
| 207 | +const limit = options.limit ?? DEFAULT_LIMIT; |
109 | 208 |
|
110 | 209 | return {
|
111 | 210 | name: INTEGRATION_NAME,
|
112 |
| -processEvent(originalEvent, hint) { |
113 |
| -const processedEvent = applyZodErrorsToEvent(limit, originalEvent, hint); |
| 211 | +processEvent(originalEvent, hint): Event { |
| 212 | +const processedEvent = applyZodErrorsToEvent(limit, options.saveZodIssuesAsAttachment, originalEvent, hint); |
114 | 213 | return processedEvent;
|
115 | 214 | },
|
116 | 215 | };
|
117 | 216 | }) satisfies IntegrationFn;
|
118 | 217 |
|
| 218 | +/** |
| 219 | +* Sentry integration to process Zod errors, making them easier to work with in Sentry. |
| 220 | +*/ |
119 | 221 | export const zodErrorsIntegration = defineIntegration(_zodErrorsIntegration);
|
0 commit comments