From eadcac5b883ea652b5fac0daa77c9129cc696f42 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 2 May 2024 00:14:56 -0700 Subject: [PATCH] feat(integrations): Add zod integration (#11144) 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. before ![image](https://github.com/getsentry/sentry-javascript/assets/1400464/835f4388-398b-42bf-9c6c-dae111207de8) ![image](https://github.com/getsentry/sentry-javascript/assets/1400464/1647b16d-3990-4726-805f-93ad863f71ea) after ![image](https://github.com/getsentry/sentry-javascript/assets/1400464/561751c3-1455-41f5-b700-8116daae419f) ![image](https://github.com/getsentry/sentry-javascript/assets/1400464/3c6df13a-6c0e-46fd-9631-80345743c061) ![image](https://github.com/getsentry/sentry-javascript/assets/1400464/1556cad3-2b78-42af-be1c-c8cb9d79fb4a) --------- Co-authored-by: Francesco Novy --- packages/aws-serverless/src/index.ts | 1 + packages/browser/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/integrations/zoderrors.ts | 119 ++++++++++++++++++ .../test/lib/integrations/zoderrrors.test.ts | 100 +++++++++++++++ packages/deno/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + packages/vercel-edge/src/index.ts | 1 + 10 files changed, 227 insertions(+) create mode 100644 packages/core/src/integrations/zoderrors.ts create mode 100644 packages/core/test/lib/integrations/zoderrrors.test.ts diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index c892e8fd373c..dcf248f05112 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/browser/src/index.ts b/packages/browser/src/index.ts index 23b042ca8e09..04a98fbacb02 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -64,6 +64,7 @@ export { setHttpStatus, makeMultiplexedTransport, moduleMetadataIntegration, + zodErrorsIntegration, } from '@sentry/core'; export type { Span } from '@sentry/types'; export { makeBrowserOfflineTransport } from './transports/offline'; 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/core/src/index.ts b/packages/core/src/index.ts index df889035b7c4..c2c210262483 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,6 +91,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..14a7da84d384 --- /dev/null +++ b/packages/core/src/integrations/zoderrors.ts @@ -0,0 +1,119 @@ +import type { IntegrationFn } from '@sentry/types'; +import type { Event, EventHint } from '@sentry/types'; +import { isError, truncate } from '@sentry/utils'; +import { defineIntegration } from '../integration'; + +interface ZodErrorsOptions { + key?: string; + limit?: number; +} + +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; + + 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/core/test/lib/integrations/zoderrrors.test.ts b/packages/core/test/lib/integrations/zoderrrors.test.ts new file mode 100644 index 000000000000..2eca38d5d2ab --- /dev/null +++ b/packages/core/test/lib/integrations/zoderrrors.test.ts @@ -0,0 +1,100 @@ +import type { Event, EventHint } from '@sentry/types'; + +import { applyZodErrorsToEvent } from '../../../src/integrations/zoderrors'; + +// 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[] = []; + + // 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; + }; +} + +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, + }, + ], + }); + }); +}); 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/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 { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a44561e969c9..fd71dc874974 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -109,6 +109,7 @@ export { spanToJSON, spanToTraceHeader, trpcMiddleware, + zodErrorsIntegration, } from '@sentry/core'; export type { 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,