From 2102971cfe7b3eebc708cdf70c15fa55f4686bc8 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 15 Mar 2024 15:56:29 -0700 Subject: [PATCH 1/6] feat(integrations): Add zod integration This adds a [Zod](https://github.com/colinhacks/zod) integration to sentry that adds better support for ZodError issues. Currently, the ZodError message is a formatted json string that gets truncated and the full list of issues are lost. - Adds the full list of issues to `extras['zoderror.issues']`. - Replaces the error message with a simple string. --- packages/core/src/index.ts | 1 + packages/core/src/integrations/zoderrors.ts | 25 +++++ packages/node/src/index.ts | 1 + packages/utils/src/index.ts | 1 + packages/utils/src/zod-errors.ts | 100 +++++++++++++++++ packages/utils/test/zod-errors.test.ts | 115 ++++++++++++++++++++ 6 files changed, 243 insertions(+) create mode 100644 packages/core/src/integrations/zoderrors.ts create mode 100644 packages/utils/src/zod-errors.ts create mode 100644 packages/utils/test/zod-errors.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbf233ad8dd8..18da68f481ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export { dedupeIntegration } from './integrations/dedupe'; export { extraErrorDataIntegration } from './integrations/extraerrordata'; export { rewriteFramesIntegration } from './integrations/rewriteframes'; export { sessionTimingIntegration } from './integrations/sessiontiming'; +export { zodErrorsIntegration } from './integrations/zoderrors'; export { metrics } from './metrics/exports'; export type { MetricData } from './metrics/exports'; export { metricsDefault } from './metrics/exports-default'; diff --git a/packages/core/src/integrations/zoderrors.ts b/packages/core/src/integrations/zoderrors.ts new file mode 100644 index 000000000000..fc6c0a4b8bc6 --- /dev/null +++ b/packages/core/src/integrations/zoderrors.ts @@ -0,0 +1,25 @@ +import type { IntegrationFn } from '@sentry/types'; +import { applyZodErrorsToEvent } from '@sentry/utils'; +import { defineIntegration } from '../integration'; + +interface ZodErrorsOptions { + key?: string; + limit?: number; +} + +const DEFAULT_LIMIT = 10; +const INTEGRATION_NAME = 'ZodErrors'; + +const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => { + const limit = options.limit || DEFAULT_LIMIT; + + return { + name: INTEGRATION_NAME, + processEvent(originalEvent, hint) { + const processedEvent = applyZodErrorsToEvent(limit, originalEvent, hint); + return processedEvent; + }, + }; +}) satisfies IntegrationFn; + +export const zodErrorsIntegration = defineIntegration(_zodErrorsIntegration); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 73cd82c93b32..0a8fdc065469 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -117,6 +117,7 @@ export { extraErrorDataIntegration, rewriteFramesIntegration, sessionTimingIntegration, + zodErrorsIntegration, } from '@sentry/core'; export { consoleIntegration } from './integrations/console'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5c714e8fbd28..cf1ae1022d8d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,3 +36,4 @@ export * from './eventbuilder'; export * from './anr'; export * from './lru'; export * from './buildPolyfills'; +export * from './zod-errors'; diff --git a/packages/utils/src/zod-errors.ts b/packages/utils/src/zod-errors.ts new file mode 100644 index 000000000000..93fb276ad844 --- /dev/null +++ b/packages/utils/src/zod-errors.ts @@ -0,0 +1,100 @@ +import type { Event, EventHint } from '@sentry/types'; + +import { isError } from './is'; +import { truncate } from './string'; + +// Simplified ZodIssue type definition +interface ZodIssue { + path: (string | number)[]; + message?: string; + expected?: string | number; + received?: string | number; + unionErrors?: unknown[]; + keys?: unknown[]; +} + +interface FlattenedZodResult { + formErrors: unknown[]; + fieldErrors: Record; +} + +interface ZodError extends Error { + issues: ZodIssue[]; + + get errors(): ZodError['issues']; + flatten(): FlattenedZodResult; +} + +function originalExceptionIsZodError(originalException: unknown): originalException is ZodError { + return ( + isError(originalException) && + originalException.name === 'ZodError' && + Array.isArray((originalException as ZodError).errors) && + typeof (originalException as ZodError).flatten === 'function' + ); +} + +type SingleLevelZodIssue = { + [P in keyof T]: T[P] extends string | number | undefined + ? T[P] + : T[P] extends unknown[] + ? string | undefined + : unknown; +}; + +/** + * Formats child objects or arrays to a string + * That is preserved when sent to Sentry + */ +function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue { + return { + ...issue, + path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined, + keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined, + unionErrors: 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined, + }; +} + +/** + * Zod error message is a stringified version of ZodError.issues + * This doesn't display well in the Sentry UI. Replace it with something shorter. + */ +function formatIssueMessage(zodError: ZodError): string { + const formError = zodError.flatten(); + const errorKeys = Object.keys(formError.fieldErrors); + return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`; +} + +/** + * Applies ZodError issues to an event extras and replaces the error message + */ +export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventHint): Event { + if ( + !event.exception || + !event.exception.values || + !hint || + !hint.originalException || + !originalExceptionIsZodError(hint.originalException) || + hint.originalException.issues.length === 0 + ) { + return event; + } + + return { + ...event, + exception: { + ...event.exception, + values: [ + { + ...event.exception.values[0], + value: formatIssueMessage(hint.originalException), + }, + ...event.exception.values.slice(1), + ], + }, + extra: { + ...event.extra, + 'zoderror.issues': hint.originalException.errors.slice(0, limit).map(formatIssueTitle), + }, + }; +} diff --git a/packages/utils/test/zod-errors.test.ts b/packages/utils/test/zod-errors.test.ts new file mode 100644 index 000000000000..98a610a7985a --- /dev/null +++ b/packages/utils/test/zod-errors.test.ts @@ -0,0 +1,115 @@ +import type { Event, EventHint } from '@sentry/types'; + +import { applyZodErrorsToEvent } from '../src/index'; + +// Simplified type definition +interface ZodIssue { + code: string; + path: (string | number)[]; + expected?: string | number; + received?: string | number; + keys?: string[]; + message?: string; +} + +class ZodError extends Error { + issues: ZodIssue[] = []; + + // Eslint disabled to match what exists in Zod + // https://github.com/colinhacks/zod/blob/8910033b861c842df59919e7d45e7f51cf8b76a2/src/ZodError.ts#L199C1-L211C4 + constructor(issues: ZodIssue[]) { + super(); + + const actualProto = new.target.prototype; + if (Object.setPrototypeOf) { + Object.setPrototypeOf(this, actualProto); + } else { + (this as any).__proto__ = actualProto; + } + + this.name = 'ZodError'; + this.issues = issues; + } + + get errors() { + return this.issues; + } + + static create = (issues: ZodIssue[]) => { + const error = new ZodError(issues); + return error; + }; + + flatten() { + const fieldErrors: any = {}; + const formErrors: any[] = []; + for (const sub of this.issues) { + if (sub.path.length > 0) { + fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || []; + fieldErrors[sub.path[0]].push(sub); + } else { + formErrors.push(sub); + } + } + return { formErrors, fieldErrors }; + } +} + +describe('applyZodErrorsToEvent()', () => { + test('should not do anything if exception is not a ZodError', () => { + const event: Event = {}; + const eventHint: EventHint = { originalException: new Error() }; + applyZodErrorsToEvent(100, event, eventHint); + + // no changes + expect(event).toStrictEqual({}); + }); + + test('should add ZodError issues to extras and format message', () => { + const issues = [ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['names', 1], + keys: ['extra'], + message: 'Invalid input: expected string, received number', + }, + ] satisfies ZodIssue[]; + const originalException = ZodError.create(issues); + + const event: Event = { + exception: { + values: [ + { + type: 'Error', + value: originalException.message, + }, + ], + }, + }; + + const eventHint: EventHint = { originalException }; + const processedEvent = applyZodErrorsToEvent(100, event, eventHint); + + expect(processedEvent.exception).toStrictEqual({ + values: [ + { + type: 'Error', + value: 'Failed to validate keys: names', + }, + ], + }); + + expect(processedEvent.extra).toStrictEqual({ + 'zoderror.issues': [ + { + ...issues[0], + path: issues[0].path.join('.'), + keys: JSON.stringify(issues[0].keys), + unionErrors: undefined, + }, + ], + }); + }); +}); From 5871852f9d76edc6f8a3a8ee36ba3c430733e894 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 18 Apr 2024 11:30:48 -0700 Subject: [PATCH 2/6] consistent exports --- packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 07cc52b2467a..1f483f552557 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -97,6 +97,7 @@ export { spanToTraceHeader, trpcMiddleware, addOpenTelemetryInstrumentation, + zodErrorsIntegration, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 329af504f73f..47b40a9e19d7 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -118,6 +118,7 @@ export { spanToTraceHeader, trpcMiddleware, addOpenTelemetryInstrumentation, + zodErrorsIntegration, } from '@sentry/node'; export { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 0bfd796bb297..eb1e47b2fd05 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -97,6 +97,7 @@ export { spanToTraceHeader, trpcMiddleware, addOpenTelemetryInstrumentation, + zodErrorsIntegration, } from '@sentry/node'; export { From 7acc79cf816c46e3c8a4294d4332d79bbf4a907a Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 24 Apr 2024 13:00:03 -0700 Subject: [PATCH 3/6] combine packages, remove flatten --- packages/core/src/integrations/zoderrors.ts | 96 ++++++++++++++++- .../test/lib/integrations/zoderrrors.test.ts} | 16 +-- packages/utils/src/zod-errors.ts | 100 ------------------ 3 files changed, 96 insertions(+), 116 deletions(-) rename packages/{utils/test/zod-errors.test.ts => core/test/lib/integrations/zoderrrors.test.ts} (85%) delete mode 100644 packages/utils/src/zod-errors.ts diff --git a/packages/core/src/integrations/zoderrors.ts b/packages/core/src/integrations/zoderrors.ts index fc6c0a4b8bc6..14a7da84d384 100644 --- a/packages/core/src/integrations/zoderrors.ts +++ b/packages/core/src/integrations/zoderrors.ts @@ -1,5 +1,6 @@ import type { IntegrationFn } from '@sentry/types'; -import { applyZodErrorsToEvent } from '@sentry/utils'; +import type { Event, EventHint } from '@sentry/types'; +import { isError, truncate } from '@sentry/utils'; import { defineIntegration } from '../integration'; interface ZodErrorsOptions { @@ -10,6 +11,99 @@ interface ZodErrorsOptions { const DEFAULT_LIMIT = 10; const INTEGRATION_NAME = 'ZodErrors'; +// Simplified ZodIssue type definition +interface ZodIssue { + path: (string | number)[]; + message?: string; + expected?: string | number; + received?: string | number; + unionErrors?: unknown[]; + keys?: unknown[]; +} + +interface ZodError extends Error { + issues: ZodIssue[]; + + get errors(): ZodError['issues']; +} + +function originalExceptionIsZodError(originalException: unknown): originalException is ZodError { + return ( + isError(originalException) && + originalException.name === 'ZodError' && + Array.isArray((originalException as ZodError).errors) + ); +} + +type SingleLevelZodIssue = { + [P in keyof T]: T[P] extends string | number | undefined + ? T[P] + : T[P] extends unknown[] + ? string | undefined + : unknown; +}; + +/** + * Formats child objects or arrays to a string + * That is preserved when sent to Sentry + */ +function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue { + return { + ...issue, + path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined, + keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined, + unionErrors: 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined, + }; +} + +/** + * Zod error message is a stringified version of ZodError.issues + * This doesn't display well in the Sentry UI. Replace it with something shorter. + */ +function formatIssueMessage(zodError: ZodError): string { + const errorKeyMap = new Set(); + for (const iss of zodError.issues) { + if (iss.path) errorKeyMap.add(iss.path[0]); + } + const errorKeys = Array.from(errorKeyMap); + + return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`; +} + +/** + * Applies ZodError issues to an event extras and replaces the error message + */ +export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventHint): Event { + if ( + !event.exception || + !event.exception.values || + !hint || + !hint.originalException || + !originalExceptionIsZodError(hint.originalException) || + hint.originalException.issues.length === 0 + ) { + return event; + } + + return { + ...event, + exception: { + ...event.exception, + values: [ + { + ...event.exception.values[0], + value: formatIssueMessage(hint.originalException), + }, + ...event.exception.values.slice(1), + ], + }, + extra: { + ...event.extra, + 'zoderror.issues': hint.originalException.errors.slice(0, limit).map(formatIssueTitle), + }, + }; +} + const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => { const limit = options.limit || DEFAULT_LIMIT; diff --git a/packages/utils/test/zod-errors.test.ts b/packages/core/test/lib/integrations/zoderrrors.test.ts similarity index 85% rename from packages/utils/test/zod-errors.test.ts rename to packages/core/test/lib/integrations/zoderrrors.test.ts index 98a610a7985a..6c53c5e72e9e 100644 --- a/packages/utils/test/zod-errors.test.ts +++ b/packages/core/test/lib/integrations/zoderrrors.test.ts @@ -1,6 +1,6 @@ import type { Event, EventHint } from '@sentry/types'; -import { applyZodErrorsToEvent } from '../src/index'; +import { applyZodErrorsToEvent } from '../../../src/integrations/zoderrors'; // Simplified type definition interface ZodIssue { @@ -39,20 +39,6 @@ class ZodError extends Error { const error = new ZodError(issues); return error; }; - - flatten() { - const fieldErrors: any = {}; - const formErrors: any[] = []; - for (const sub of this.issues) { - if (sub.path.length > 0) { - fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || []; - fieldErrors[sub.path[0]].push(sub); - } else { - formErrors.push(sub); - } - } - return { formErrors, fieldErrors }; - } } describe('applyZodErrorsToEvent()', () => { diff --git a/packages/utils/src/zod-errors.ts b/packages/utils/src/zod-errors.ts deleted file mode 100644 index 93fb276ad844..000000000000 --- a/packages/utils/src/zod-errors.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Event, EventHint } from '@sentry/types'; - -import { isError } from './is'; -import { truncate } from './string'; - -// Simplified ZodIssue type definition -interface ZodIssue { - path: (string | number)[]; - message?: string; - expected?: string | number; - received?: string | number; - unionErrors?: unknown[]; - keys?: unknown[]; -} - -interface FlattenedZodResult { - formErrors: unknown[]; - fieldErrors: Record; -} - -interface ZodError extends Error { - issues: ZodIssue[]; - - get errors(): ZodError['issues']; - flatten(): FlattenedZodResult; -} - -function originalExceptionIsZodError(originalException: unknown): originalException is ZodError { - return ( - isError(originalException) && - originalException.name === 'ZodError' && - Array.isArray((originalException as ZodError).errors) && - typeof (originalException as ZodError).flatten === 'function' - ); -} - -type SingleLevelZodIssue = { - [P in keyof T]: T[P] extends string | number | undefined - ? T[P] - : T[P] extends unknown[] - ? string | undefined - : unknown; -}; - -/** - * Formats child objects or arrays to a string - * That is preserved when sent to Sentry - */ -function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue { - return { - ...issue, - path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined, - keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined, - unionErrors: 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined, - }; -} - -/** - * Zod error message is a stringified version of ZodError.issues - * This doesn't display well in the Sentry UI. Replace it with something shorter. - */ -function formatIssueMessage(zodError: ZodError): string { - const formError = zodError.flatten(); - const errorKeys = Object.keys(formError.fieldErrors); - return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`; -} - -/** - * Applies ZodError issues to an event extras and replaces the error message - */ -export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventHint): Event { - if ( - !event.exception || - !event.exception.values || - !hint || - !hint.originalException || - !originalExceptionIsZodError(hint.originalException) || - hint.originalException.issues.length === 0 - ) { - return event; - } - - return { - ...event, - exception: { - ...event.exception, - values: [ - { - ...event.exception.values[0], - value: formatIssueMessage(hint.originalException), - }, - ...event.exception.values.slice(1), - ], - }, - extra: { - ...event.extra, - 'zoderror.issues': hint.originalException.errors.slice(0, limit).map(formatIssueTitle), - }, - }; -} From 5dc3ec2e3aadb259597b63b74fd222c0e1891293 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 24 Apr 2024 14:14:23 -0700 Subject: [PATCH 4/6] remove reference --- packages/utils/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index db8499de7bb2..a0649cef48ad 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -35,4 +35,3 @@ export * from './eventbuilder'; export * from './anr'; export * from './lru'; export * from './buildPolyfills'; -export * from './zod-errors'; From fc07bd48e2d34c305f0855de870ab70f1ab5b537 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 26 Apr 2024 09:22:33 -0700 Subject: [PATCH 5/6] remove useless comment --- packages/core/test/lib/integrations/zoderrrors.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/test/lib/integrations/zoderrrors.test.ts b/packages/core/test/lib/integrations/zoderrrors.test.ts index 6c53c5e72e9e..2eca38d5d2ab 100644 --- a/packages/core/test/lib/integrations/zoderrrors.test.ts +++ b/packages/core/test/lib/integrations/zoderrrors.test.ts @@ -15,7 +15,6 @@ interface ZodIssue { class ZodError extends Error { issues: ZodIssue[] = []; - // Eslint disabled to match what exists in Zod // https://github.com/colinhacks/zod/blob/8910033b861c842df59919e7d45e7f51cf8b76a2/src/ZodError.ts#L199C1-L211C4 constructor(issues: ZodIssue[]) { super(); From 31a2374796251ed7fd81873e919f587fafd20a50 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 30 Apr 2024 09:16:55 +0200 Subject: [PATCH 6/6] export from everywhere --- packages/browser/src/index.ts | 1 + packages/deno/src/index.ts | 1 + packages/vercel-edge/src/index.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d5e905ff568c..a3772ed1116c 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -63,6 +63,7 @@ export { setHttpStatus, makeMultiplexedTransport, moduleMetadataIntegration, + zodErrorsIntegration, } from '@sentry/core'; export type { Span } from '@sentry/types'; export { makeBrowserOfflineTransport } from './transports/offline'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f25c14864d84..2e5e0cefa657 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -67,6 +67,7 @@ export { extraErrorDataIntegration, rewriteFramesIntegration, sessionTimingIntegration, + zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 963497cc619a..9c5a0705426e 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -64,6 +64,7 @@ export { inboundFiltersIntegration, linkedErrorsIntegration, requestDataIntegration, + zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,