Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(integrations): Add zod integration #11144

Merged
merged 9 commits into from May 2, 2024
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Expand Up @@ -97,6 +97,7 @@ export {
spanToTraceHeader,
trpcMiddleware,
addOpenTelemetryInstrumentation,
zodErrorsIntegration,
} from '@sentry/node';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Expand Up @@ -118,6 +118,7 @@ export {
spanToTraceHeader,
trpcMiddleware,
addOpenTelemetryInstrumentation,
zodErrorsIntegration,
} from '@sentry/node';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Expand Up @@ -92,6 +92,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';
Expand Down
25 changes: 25 additions & 0 deletions 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);
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Expand Up @@ -97,6 +97,7 @@ export {
spanToTraceHeader,
trpcMiddleware,
addOpenTelemetryInstrumentation,
zodErrorsIntegration,
} from '@sentry/node';

export {
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Expand Up @@ -109,6 +109,7 @@ export {
spanToJSON,
spanToTraceHeader,
trpcMiddleware,
zodErrorsIntegration,
} from '@sentry/core';

export type {
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Expand Up @@ -35,3 +35,4 @@ export * from './eventbuilder';
export * from './anr';
export * from './lru';
export * from './buildPolyfills';
export * from './zod-errors';
100 changes: 100 additions & 0 deletions 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<string, unknown[]>;
}

interface ZodError extends Error {
issues: ZodIssue[];

get errors(): ZodError['issues'];
flatten(): FlattenedZodResult;
}

function originalExceptionIsZodError(originalException: unknown): originalException is ZodError {
scttcper marked this conversation as resolved.
Show resolved Hide resolved
return (
isError(originalException) &&
originalException.name === 'ZodError' &&
Array.isArray((originalException as ZodError).errors) &&
typeof (originalException as ZodError).flatten === 'function'
);
}

type SingleLevelZodIssue<T extends ZodIssue> = {
[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<ZodIssue> {
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();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend against relying on .flatten here as it's liable to change in Zod 4. Instead consider iterating over the .issues array.

declare let err: z.ZodError;
const errorKeyMap = new Set<string | number | symbol>();
for (const iss of err.issues) {
  if (iss.path) errorKeyMap.add(iss.path[0]);
}
const errorKeys = Array.from(errorKeyMap);

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),
},
};
}
115 changes: 115 additions & 0 deletions 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,
},
],
});
});
});