Skip to content

Commit a0138f7

Browse files
authored
feat: Add zodErrorsIntegration (#252)
This integration improves the format of errors recorded to Sentry when using Zod
1 parent 0365687 commit a0138f7

File tree

7 files changed

+560
-0
lines changed

7 files changed

+560
-0
lines changed

.changeset/gentle-turkeys-flow.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'toucan-js': minor
3+
---
4+
5+
feat: Add zodErrorsIntegration
6+
7+
This integration improves the format of errors recorded to Sentry when using Zod

packages/toucan-js/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Supported integrations from [@sentry/core](https://github.com/getsentry/sentry-j
6060

6161
- [linkedErrorsIntegration](src/integrations/linkedErrors.ts)
6262
- [requestDataIntegration](src/integrations/requestData.ts)
63+
- [zodErrorsIntegration](src/integrations/zod/zoderrors.ts)
6364

6465
### Custom integration example:
6566

packages/toucan-js/src/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './linkedErrors';
22
export * from './requestData';
3+
export { zodErrorsIntegration } from './zod/zoderrors';
34
export {
45
dedupeIntegration,
56
extraErrorDataIntegration,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Integration, IntegrationFn } from '@sentry/types';
2+
3+
/**
4+
* Define an integration function that can be used to create an integration instance.
5+
* Note that this by design hides the implementation details of the integration, as they are considered internal.
6+
*
7+
* Inlined from https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/integration.ts#L165
8+
*/
9+
export function defineIntegration<Fn extends IntegrationFn>(
10+
fn: Fn,
11+
): (...args: Parameters<Fn>) => Integration {
12+
return fn;
13+
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { describe, expect, it, test } from '@jest/globals';
2+
import { z } from 'zod';
3+
4+
import {
5+
flattenIssue,
6+
flattenIssuePath,
7+
formatIssueMessage,
8+
} from './zoderrors';
9+
10+
describe('flattenIssue()', () => {
11+
it('flattens path field', () => {
12+
const zodError = z
13+
.object({
14+
foo: z.string().min(1),
15+
nested: z.object({
16+
bar: z.literal('baz'),
17+
}),
18+
})
19+
.safeParse({
20+
foo: '',
21+
nested: {
22+
bar: 'not-baz',
23+
},
24+
}).error;
25+
if (zodError === undefined) {
26+
throw new Error('zodError is undefined');
27+
}
28+
29+
// Original zod error
30+
expect(zodError.issues).toMatchInlineSnapshot(`
31+
[
32+
{
33+
"code": "too_small",
34+
"exact": false,
35+
"inclusive": true,
36+
"message": "String must contain at least 1 character(s)",
37+
"minimum": 1,
38+
"path": [
39+
"foo",
40+
],
41+
"type": "string",
42+
},
43+
{
44+
"code": "invalid_literal",
45+
"expected": "baz",
46+
"message": "Invalid literal value, expected "baz"",
47+
"path": [
48+
"nested",
49+
"bar",
50+
],
51+
"received": "not-baz",
52+
},
53+
]
54+
`);
55+
56+
const issues = zodError.issues;
57+
expect(issues.length).toBe(2);
58+
59+
// Format it for use in Sentry
60+
expect(issues.map(flattenIssue)).toMatchInlineSnapshot(`
61+
[
62+
{
63+
"code": "too_small",
64+
"exact": false,
65+
"inclusive": true,
66+
"keys": undefined,
67+
"message": "String must contain at least 1 character(s)",
68+
"minimum": 1,
69+
"path": "foo",
70+
"type": "string",
71+
"unionErrors": undefined,
72+
},
73+
{
74+
"code": "invalid_literal",
75+
"expected": "baz",
76+
"keys": undefined,
77+
"message": "Invalid literal value, expected "baz"",
78+
"path": "nested.bar",
79+
"received": "not-baz",
80+
"unionErrors": undefined,
81+
},
82+
]
83+
`);
84+
});
85+
86+
it('flattens keys field to string', () => {
87+
const zodError = z
88+
.object({
89+
foo: z.string().min(1),
90+
})
91+
.strict()
92+
.safeParse({
93+
foo: 'bar',
94+
extra_key_abc: 'hello',
95+
extra_key_def: 'world',
96+
}).error;
97+
if (zodError === undefined) {
98+
throw new Error('zodError is undefined');
99+
}
100+
101+
// Original zod error
102+
expect(zodError.issues).toMatchInlineSnapshot(`
103+
[
104+
{
105+
"code": "unrecognized_keys",
106+
"keys": [
107+
"extra_key_abc",
108+
"extra_key_def",
109+
],
110+
"message": "Unrecognized key(s) in object: 'extra_key_abc', 'extra_key_def'",
111+
"path": [],
112+
},
113+
]
114+
`);
115+
116+
const issues = zodError.issues;
117+
expect(issues.length).toBe(1);
118+
119+
// Format it for use in Sentry
120+
const formattedIssue = flattenIssue(issues[0]);
121+
122+
// keys is now a string rather than array.
123+
// Note: path is an empty string because the issue is at the root.
124+
// TODO: Maybe somehow make it clearer that this is at the root?
125+
expect(formattedIssue).toMatchInlineSnapshot(`
126+
{
127+
"code": "unrecognized_keys",
128+
"keys": "["extra_key_abc","extra_key_def"]",
129+
"message": "Unrecognized key(s) in object: 'extra_key_abc', 'extra_key_def'",
130+
"path": "",
131+
"unionErrors": undefined,
132+
}
133+
`);
134+
expect(typeof formattedIssue.keys === 'string').toBe(true);
135+
});
136+
});
137+
138+
describe('flattenIssuePath()', () => {
139+
it('returns single path', () => {
140+
expect(flattenIssuePath(['foo'])).toBe('foo');
141+
});
142+
143+
it('flattens nested string paths', () => {
144+
expect(flattenIssuePath(['foo', 'bar'])).toBe('foo.bar');
145+
});
146+
147+
it('uses placeholder for path index within array', () => {
148+
expect(flattenIssuePath([0, 'foo', 1, 'bar'])).toBe(
149+
'<array>.foo.<array>.bar',
150+
);
151+
});
152+
});
153+
154+
describe('formatIssueMessage()', () => {
155+
it('adds invalid keys to message', () => {
156+
const zodError = z
157+
.object({
158+
foo: z.string().min(1),
159+
nested: z.object({
160+
bar: z.literal('baz'),
161+
}),
162+
})
163+
.safeParse({
164+
foo: '',
165+
nested: {
166+
bar: 'not-baz',
167+
},
168+
}).error;
169+
if (zodError === undefined) {
170+
throw new Error('zodError is undefined');
171+
}
172+
173+
const message = formatIssueMessage(zodError);
174+
expect(message).toMatchInlineSnapshot(
175+
`"Failed to validate keys: foo, nested.bar"`,
176+
);
177+
});
178+
179+
describe('adds expected type if root variable is invalid', () => {
180+
test('object', () => {
181+
const zodError = z
182+
.object({
183+
foo: z.string().min(1),
184+
})
185+
.safeParse(123).error;
186+
if (zodError === undefined) {
187+
throw new Error('zodError is undefined');
188+
}
189+
190+
// Original zod error
191+
expect(zodError.issues).toMatchInlineSnapshot(`
192+
[
193+
{
194+
"code": "invalid_type",
195+
"expected": "object",
196+
"message": "Expected object, received number",
197+
"path": [],
198+
"received": "number",
199+
},
200+
]
201+
`);
202+
203+
const message = formatIssueMessage(zodError);
204+
expect(message).toMatchInlineSnapshot(`"Failed to validate object"`);
205+
});
206+
207+
test('number', () => {
208+
const zodError = z.number().safeParse('123').error;
209+
if (zodError === undefined) {
210+
throw new Error('zodError is undefined');
211+
}
212+
213+
// Original zod error
214+
expect(zodError.issues).toMatchInlineSnapshot(`
215+
[
216+
{
217+
"code": "invalid_type",
218+
"expected": "number",
219+
"message": "Expected number, received string",
220+
"path": [],
221+
"received": "string",
222+
},
223+
]
224+
`);
225+
226+
const message = formatIssueMessage(zodError);
227+
expect(message).toMatchInlineSnapshot(`"Failed to validate number"`);
228+
});
229+
230+
test('string', () => {
231+
const zodError = z.string().safeParse(123).error;
232+
if (zodError === undefined) {
233+
throw new Error('zodError is undefined');
234+
}
235+
236+
// Original zod error
237+
expect(zodError.issues).toMatchInlineSnapshot(`
238+
[
239+
{
240+
"code": "invalid_type",
241+
"expected": "string",
242+
"message": "Expected string, received number",
243+
"path": [],
244+
"received": "number",
245+
},
246+
]
247+
`);
248+
249+
const message = formatIssueMessage(zodError);
250+
expect(message).toMatchInlineSnapshot(`"Failed to validate string"`);
251+
});
252+
253+
test('array', () => {
254+
const zodError = z.string().array().safeParse('123').error;
255+
if (zodError === undefined) {
256+
throw new Error('zodError is undefined');
257+
}
258+
259+
// Original zod error
260+
expect(zodError.issues).toMatchInlineSnapshot(`
261+
[
262+
{
263+
"code": "invalid_type",
264+
"expected": "array",
265+
"message": "Expected array, received string",
266+
"path": [],
267+
"received": "string",
268+
},
269+
]
270+
`);
271+
272+
const message = formatIssueMessage(zodError);
273+
expect(message).toMatchInlineSnapshot(`"Failed to validate array"`);
274+
});
275+
276+
test('wrong type in array', () => {
277+
const zodError = z.string().array().safeParse([123]).error;
278+
if (zodError === undefined) {
279+
throw new Error('zodError is undefined');
280+
}
281+
282+
// Original zod error
283+
expect(zodError.issues).toMatchInlineSnapshot(`
284+
[
285+
{
286+
"code": "invalid_type",
287+
"expected": "string",
288+
"message": "Expected string, received number",
289+
"path": [
290+
0,
291+
],
292+
"received": "number",
293+
},
294+
]
295+
`);
296+
297+
const message = formatIssueMessage(zodError);
298+
expect(message).toMatchInlineSnapshot(
299+
`"Failed to validate keys: <array>"`,
300+
);
301+
});
302+
});
303+
});

0 commit comments

Comments
 (0)