@@ -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