Skip to content

Commit a3ddff6

Browse files
AbhiPrasadjahands
andauthored
feat(v8/core): Improve error formatting in ZodErrors integration (#15155)
- 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 Co-authored-by: Jacob Hands <jacob@gogit.io>
1 parent b468ab0 commit a3ddff6

File tree

5 files changed

+1239
-47
lines changed

5 files changed

+1239
-47
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Now, there are two options to set up the SDK:
5454
);
5555
```
5656

57+
Work in this release was contributed by @jahands. Thank you for your contribution!
58+
5759
## 8.51.0
5860

5961
### Important Changes

packages/core/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"TODO(v9):": "Remove these dependencies",
4242
"devDependencies": {
4343
"@types/array.prototype.flat": "^1.2.1",
44-
"array.prototype.flat": "^1.3.0"
44+
"array.prototype.flat": "^1.3.0",
45+
"zod": "^3.24.1"
4546
},
4647
"scripts": {
4748
"build": "run-p build:transpile build:types",

packages/core/src/integrations/zoderrors.ts

+135-33
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,26 +77,70 @@ 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 && 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 ||
81146
!event.exception.values ||
@@ -87,35 +152,72 @@ export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventH
87152
return event;
88153
}
89154

90-
return {
91-
...event,
92-
exception: {
93-
...event.exception,
94-
values: [
95-
{
96-
...event.exception.values[0],
97-
value: formatIssueMessage(hint.originalException),
155+
try {
156+
const issuesToFlatten = saveZodIssuesAsAttachment
157+
? hint.originalException.issues
158+
: hint.originalException.issues.slice(0, limit);
159+
const flattenedIssues = issuesToFlatten.map(flattenIssue);
160+
161+
if (saveZodIssuesAsAttachment) {
162+
// Sometimes having the full error details can be helpful.
163+
// Attachments have much higher limits, so we can include the full list of issues.
164+
if (!Array.isArray(hint.attachments)) {
165+
hint.attachments = [];
166+
}
167+
hint.attachments.push({
168+
filename: 'zod_issues.json',
169+
data: JSON.stringify({
170+
issues: flattenedIssues,
171+
}),
172+
});
173+
}
174+
175+
return {
176+
...event,
177+
exception: {
178+
...event.exception,
179+
values: [
180+
{
181+
...event.exception.values[0],
182+
value: formatIssueMessage(hint.originalException),
183+
},
184+
...event.exception.values.slice(1),
185+
],
186+
},
187+
extra: {
188+
...event.extra,
189+
'zoderror.issues': flattenedIssues.slice(0, limit),
190+
},
191+
};
192+
} catch (e) {
193+
// Hopefully we never throw errors here, but record it
194+
// with the event just in case.
195+
return {
196+
...event,
197+
extra: {
198+
...event.extra,
199+
'zoderrors sentry integration parse error': {
200+
message: 'an exception was thrown while processing ZodError within applyZodErrorsToEvent()',
201+
error: e instanceof Error ? `${e.name}: ${e.message}\n${e.stack}` : 'unknown',
98202
},
99-
...event.exception.values.slice(1),
100-
],
101-
},
102-
extra: {
103-
...event.extra,
104-
'zoderror.issues': hint.originalException.errors.slice(0, limit).map(formatIssueTitle),
105-
},
106-
};
203+
},
204+
};
205+
}
107206
}
108207

109208
const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => {
110-
const limit = options.limit || DEFAULT_LIMIT;
209+
const limit = typeof options.limit === 'undefined' ? DEFAULT_LIMIT : options.limit;
111210

112211
return {
113212
name: INTEGRATION_NAME,
114-
processEvent(originalEvent, hint) {
115-
const processedEvent = applyZodErrorsToEvent(limit, originalEvent, hint);
213+
processEvent(originalEvent, hint): Event {
214+
const processedEvent = applyZodErrorsToEvent(limit, options.saveZodIssuesAsAttachment, originalEvent, hint);
116215
return processedEvent;
117216
},
118217
};
119218
}) satisfies IntegrationFn;
120219

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

0 commit comments

Comments
 (0)