Skip to content

Commit 965185c

Browse files
authored
feat(core): Improve error formatting in ZodErrors integration (#15111)
- Include full key path rather than the top level key in title - Improve message for validation issues with no path - Add option to include extended issue information as an attachment
1 parent 6b66a28 commit 965185c

File tree

4 files changed

+576
-74
lines changed

4 files changed

+576
-74
lines changed

packages/core/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,8 @@
6161
"volta": {
6262
"extends": "../../package.json"
6363
},
64-
"sideEffects": false
64+
"sideEffects": false,
65+
"devDependencies": {
66+
"zod": "^3.24.1"
67+
}
6568
}

packages/core/src/integrations/zoderrors.ts

+136-34
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,45 @@ import { truncate } from '../utils-hoist/string';
55

66
interface ZodErrorsOptions {
77
key?: string;
8+
/**
9+
* Limits the number of Zod errors inlined in each Sentry event.
10+
*
11+
* @default 10
12+
*/
813
limit?: number;
14+
/**
15+
* Save full list of Zod issues as an attachment in Sentry
16+
*
17+
* @default false
18+
*/
19+
saveZodIssuesAsAttachment?: boolean;
920
}
1021

1122
const DEFAULT_LIMIT = 10;
1223
const INTEGRATION_NAME = 'ZodErrors';
1324

14-
// Simplified ZodIssue type definition
25+
/**
26+
* Simplified ZodIssue type definition
27+
*/
1528
interface ZodIssue {
1629
path: (string | number)[];
1730
message?: string;
18-
expected?: string | number;
19-
received?: string | number;
31+
expected?: unknown;
32+
received?: unknown;
2033
unionErrors?: unknown[];
2134
keys?: unknown[];
35+
invalid_literal?: unknown;
2236
}
2337

2438
interface ZodError extends Error {
2539
issues: ZodIssue[];
26-
27-
get errors(): ZodError['issues'];
2840
}
2941

3042
function originalExceptionIsZodError(originalException: unknown): originalException is ZodError {
3143
return (
3244
isError(originalException) &&
3345
originalException.name === 'ZodError' &&
34-
Array.isArray((originalException as ZodError).errors)
46+
Array.isArray((originalException as ZodError).issues)
3547
);
3648
}
3749

@@ -45,9 +57,18 @@ type SingleLevelZodIssue<T extends ZodIssue> = {
4557

4658
/**
4759
* 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+
* ]
4970
*/
50-
function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> {
71+
export function flattenIssue(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> {
5172
return {
5273
...issue,
5374
path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined,
@@ -56,64 +77,145 @@ function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> {
5677
};
5778
}
5879

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+
59104
/**
60105
* Zod error message is a stringified version of ZodError.issues
61106
* This doesn't display well in the Sentry UI. Replace it with something shorter.
62107
*/
63-
function formatIssueMessage(zodError: ZodError): string {
108+
export function formatIssueMessage(zodError: ZodError): string {
64109
const errorKeyMap = new Set<string | number | symbol>();
65110
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);
68114
}
69115
}
70-
const errorKeys = Array.from(errorKeyMap);
71116

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+
}
72132
return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`;
73133
}
74134

75135
/**
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
77137
*/
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 {
79144
if (
80145
!event.exception?.values ||
81-
!hint?.originalException ||
146+
!hint.originalException ||
82147
!originalExceptionIsZodError(hint.originalException) ||
83148
hint.originalException.issues.length === 0
84149
) {
85150
return event;
86151
}
87152

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',
96200
},
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+
}
105204
}
106205

107206
const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => {
108-
const limit = options.limit || DEFAULT_LIMIT;
207+
const limit = options.limit ?? DEFAULT_LIMIT;
109208

110209
return {
111210
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);
114213
return processedEvent;
115214
},
116215
};
117216
}) satisfies IntegrationFn;
118217

218+
/**
219+
* Sentry integration to process Zod errors, making them easier to work with in Sentry.
220+
*/
119221
export const zodErrorsIntegration = defineIntegration(_zodErrorsIntegration);

0 commit comments

Comments
 (0)