@@ -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 list of Zod issues as an attachment in Sentry
16+ *
17+ * @default false
18+ */
19+ saveZodIssuesAsAttachment ?: 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,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
109208const _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+ */
121223export const zodErrorsIntegration = defineIntegration ( _zodErrorsIntegration ) ;
0 commit comments