@@ -5,33 +5,45 @@ import { truncate } from '../utils-hoist/string';
55
66interface 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 error info as an attachment in Sentry
16+ *
17+ * @default false
18+ */
19+ saveAttachments ?: boolean ;
920}
1021
1122const DEFAULT_LIMIT = 10 ;
1223const INTEGRATION_NAME = 'ZodErrors' ;
1324
14- // Simplified ZodIssue type definition
25+ /**
26+ * Simplified ZodIssue type definition
27+ */
1528interface 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
2438interface ZodError extends Error {
2539 issues : ZodIssue [ ] ;
26-
27- get errors ( ) : ZodError [ 'issues' ] ;
2840}
2941
3042function 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,142 @@ 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+ saveAttachments : boolean | undefined ,
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 flattenedIssues = hint . originalException . issues . map ( flattenIssue ) ;
155+
156+ if ( saveAttachments === true ) {
157+ // Sometimes having the full error details can be helpful.
158+ // Attachments have much higher limits, so we can include the full list of issues.
159+ if ( ! Array . isArray ( hint . attachments ) ) {
160+ hint . attachments = [ ] ;
161+ }
162+ hint . attachments . push ( {
163+ filename : 'zod_issues.json' ,
164+ data : JSON . stringify ( {
165+ issues : flattenedIssues ,
166+ } ) ,
167+ } ) ;
168+ }
169+
170+ return {
171+ ...event ,
172+ exception : {
173+ ...event . exception ,
174+ values : [
175+ {
176+ ...event . exception . values [ 0 ] ,
177+ value : formatIssueMessage ( hint . originalException ) ,
178+ } ,
179+ ...event . exception . values . slice ( 1 ) ,
180+ ] ,
181+ } ,
182+ extra : {
183+ ...event . extra ,
184+ 'zoderror.issues' : flattenedIssues . slice ( 0 , limit ) ,
185+ } ,
186+ } ;
187+ } catch ( e ) {
188+ // Hopefully we never throw errors here, but record it
189+ // with the event just in case.
190+ return {
191+ ...event ,
192+ extra : {
193+ ...event . extra ,
194+ 'zoderrors sentry integration parse error' : {
195+ message : 'an exception was thrown while processing ZodError within applyZodErrorsToEvent()' ,
196+ error : e instanceof Error ? `${ e . name } : ${ e . message } \n${ e . stack } ` : 'unknown' ,
96197 } ,
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- } ;
198+ } ,
199+ } ;
200+ }
105201}
106202
107203const _zodErrorsIntegration = ( ( options : ZodErrorsOptions = { } ) => {
108- const limit = options . limit || DEFAULT_LIMIT ;
204+ const limit = options . limit ?? DEFAULT_LIMIT ;
109205
110206 return {
111207 name : INTEGRATION_NAME ,
112- processEvent ( originalEvent , hint ) {
113- const processedEvent = applyZodErrorsToEvent ( limit , originalEvent , hint ) ;
208+ processEvent ( originalEvent , hint ) : Event {
209+ const processedEvent = applyZodErrorsToEvent ( limit , options . saveAttachments , originalEvent , hint ) ;
114210 return processedEvent ;
115211 } ,
116212 } ;
117213} ) satisfies IntegrationFn ;
118214
215+ /**
216+ * Sentry integration to process Zod errors, making them easier to work with in Sentry.
217+ */
119218export const zodErrorsIntegration = defineIntegration ( _zodErrorsIntegration ) ;
0 commit comments