Skip to content

Commit eadcac5

Browse files
scttcpermydea
andauthored
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 <[email protected]>
1 parent 7e6c23e commit eadcac5

File tree

10 files changed

+227
-0
lines changed

10 files changed

+227
-0
lines changed

packages/aws-serverless/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export {
9797
spanToTraceHeader,
9898
trpcMiddleware,
9999
addOpenTelemetryInstrumentation,
100+
zodErrorsIntegration,
100101
} from '@sentry/node';
101102

102103
export {

packages/browser/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export {
6464
setHttpStatus,
6565
makeMultiplexedTransport,
6666
moduleMetadataIntegration,
67+
zodErrorsIntegration,
6768
} from '@sentry/core';
6869
export type { Span } from '@sentry/types';
6970
export { makeBrowserOfflineTransport } from './transports/offline';

packages/bun/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export {
118118
spanToTraceHeader,
119119
trpcMiddleware,
120120
addOpenTelemetryInstrumentation,
121+
zodErrorsIntegration,
121122
} from '@sentry/node';
122123

123124
export {

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export { dedupeIntegration } from './integrations/dedupe';
9191
export { extraErrorDataIntegration } from './integrations/extraerrordata';
9292
export { rewriteFramesIntegration } from './integrations/rewriteframes';
9393
export { sessionTimingIntegration } from './integrations/sessiontiming';
94+
export { zodErrorsIntegration } from './integrations/zoderrors';
9495
export { metrics } from './metrics/exports';
9596
export type { MetricData } from './metrics/exports';
9697
export { metricsDefault } from './metrics/exports-default';
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { IntegrationFn } from '@sentry/types';
2+
import type { Event, EventHint } from '@sentry/types';
3+
import { isError, truncate } from '@sentry/utils';
4+
import { defineIntegration } from '../integration';
5+
6+
interface ZodErrorsOptions {
7+
key?: string;
8+
limit?: number;
9+
}
10+
11+
const DEFAULT_LIMIT = 10;
12+
const INTEGRATION_NAME = 'ZodErrors';
13+
14+
// Simplified ZodIssue type definition
15+
interface ZodIssue {
16+
path: (string | number)[];
17+
message?: string;
18+
expected?: string | number;
19+
received?: string | number;
20+
unionErrors?: unknown[];
21+
keys?: unknown[];
22+
}
23+
24+
interface ZodError extends Error {
25+
issues: ZodIssue[];
26+
27+
get errors(): ZodError['issues'];
28+
}
29+
30+
function originalExceptionIsZodError(originalException: unknown): originalException is ZodError {
31+
return (
32+
isError(originalException) &&
33+
originalException.name === 'ZodError' &&
34+
Array.isArray((originalException as ZodError).errors)
35+
);
36+
}
37+
38+
type SingleLevelZodIssue<T extends ZodIssue> = {
39+
[P in keyof T]: T[P] extends string | number | undefined
40+
? T[P]
41+
: T[P] extends unknown[]
42+
? string | undefined
43+
: unknown;
44+
};
45+
46+
/**
47+
* Formats child objects or arrays to a string
48+
* That is preserved when sent to Sentry
49+
*/
50+
function formatIssueTitle(issue: ZodIssue): SingleLevelZodIssue<ZodIssue> {
51+
return {
52+
...issue,
53+
path: 'path' in issue && Array.isArray(issue.path) ? issue.path.join('.') : undefined,
54+
keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined,
55+
unionErrors: 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined,
56+
};
57+
}
58+
59+
/**
60+
* Zod error message is a stringified version of ZodError.issues
61+
* This doesn't display well in the Sentry UI. Replace it with something shorter.
62+
*/
63+
function formatIssueMessage(zodError: ZodError): string {
64+
const errorKeyMap = new Set<string | number | symbol>();
65+
for (const iss of zodError.issues) {
66+
if (iss.path) errorKeyMap.add(iss.path[0]);
67+
}
68+
const errorKeys = Array.from(errorKeyMap);
69+
70+
return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`;
71+
}
72+
73+
/**
74+
* Applies ZodError issues to an event extras and replaces the error message
75+
*/
76+
export function applyZodErrorsToEvent(limit: number, event: Event, hint?: EventHint): Event {
77+
if (
78+
!event.exception ||
79+
!event.exception.values ||
80+
!hint ||
81+
!hint.originalException ||
82+
!originalExceptionIsZodError(hint.originalException) ||
83+
hint.originalException.issues.length === 0
84+
) {
85+
return event;
86+
}
87+
88+
return {
89+
...event,
90+
exception: {
91+
...event.exception,
92+
values: [
93+
{
94+
...event.exception.values[0],
95+
value: formatIssueMessage(hint.originalException),
96+
},
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+
};
105+
}
106+
107+
const _zodErrorsIntegration = ((options: ZodErrorsOptions = {}) => {
108+
const limit = options.limit || DEFAULT_LIMIT;
109+
110+
return {
111+
name: INTEGRATION_NAME,
112+
processEvent(originalEvent, hint) {
113+
const processedEvent = applyZodErrorsToEvent(limit, originalEvent, hint);
114+
return processedEvent;
115+
},
116+
};
117+
}) satisfies IntegrationFn;
118+
119+
export const zodErrorsIntegration = defineIntegration(_zodErrorsIntegration);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Event, EventHint } from '@sentry/types';
2+
3+
import { applyZodErrorsToEvent } from '../../../src/integrations/zoderrors';
4+
5+
// Simplified type definition
6+
interface ZodIssue {
7+
code: string;
8+
path: (string | number)[];
9+
expected?: string | number;
10+
received?: string | number;
11+
keys?: string[];
12+
message?: string;
13+
}
14+
15+
class ZodError extends Error {
16+
issues: ZodIssue[] = [];
17+
18+
// https://github.com/colinhacks/zod/blob/8910033b861c842df59919e7d45e7f51cf8b76a2/src/ZodError.ts#L199C1-L211C4
19+
constructor(issues: ZodIssue[]) {
20+
super();
21+
22+
const actualProto = new.target.prototype;
23+
if (Object.setPrototypeOf) {
24+
Object.setPrototypeOf(this, actualProto);
25+
} else {
26+
(this as any).__proto__ = actualProto;
27+
}
28+
29+
this.name = 'ZodError';
30+
this.issues = issues;
31+
}
32+
33+
get errors() {
34+
return this.issues;
35+
}
36+
37+
static create = (issues: ZodIssue[]) => {
38+
const error = new ZodError(issues);
39+
return error;
40+
};
41+
}
42+
43+
describe('applyZodErrorsToEvent()', () => {
44+
test('should not do anything if exception is not a ZodError', () => {
45+
const event: Event = {};
46+
const eventHint: EventHint = { originalException: new Error() };
47+
applyZodErrorsToEvent(100, event, eventHint);
48+
49+
// no changes
50+
expect(event).toStrictEqual({});
51+
});
52+
53+
test('should add ZodError issues to extras and format message', () => {
54+
const issues = [
55+
{
56+
code: 'invalid_type',
57+
expected: 'string',
58+
received: 'number',
59+
path: ['names', 1],
60+
keys: ['extra'],
61+
message: 'Invalid input: expected string, received number',
62+
},
63+
] satisfies ZodIssue[];
64+
const originalException = ZodError.create(issues);
65+
66+
const event: Event = {
67+
exception: {
68+
values: [
69+
{
70+
type: 'Error',
71+
value: originalException.message,
72+
},
73+
],
74+
},
75+
};
76+
77+
const eventHint: EventHint = { originalException };
78+
const processedEvent = applyZodErrorsToEvent(100, event, eventHint);
79+
80+
expect(processedEvent.exception).toStrictEqual({
81+
values: [
82+
{
83+
type: 'Error',
84+
value: 'Failed to validate keys: names',
85+
},
86+
],
87+
});
88+
89+
expect(processedEvent.extra).toStrictEqual({
90+
'zoderror.issues': [
91+
{
92+
...issues[0],
93+
path: issues[0].path.join('.'),
94+
keys: JSON.stringify(issues[0].keys),
95+
unionErrors: undefined,
96+
},
97+
],
98+
});
99+
});
100+
});

packages/deno/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export {
6767
extraErrorDataIntegration,
6868
rewriteFramesIntegration,
6969
sessionTimingIntegration,
70+
zodErrorsIntegration,
7071
SEMANTIC_ATTRIBUTE_SENTRY_OP,
7172
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7273
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,

packages/google-cloud-serverless/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export {
9797
spanToTraceHeader,
9898
trpcMiddleware,
9999
addOpenTelemetryInstrumentation,
100+
zodErrorsIntegration,
100101
} from '@sentry/node';
101102

102103
export {

packages/node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export {
109109
spanToJSON,
110110
spanToTraceHeader,
111111
trpcMiddleware,
112+
zodErrorsIntegration,
112113
} from '@sentry/core';
113114

114115
export type {

packages/vercel-edge/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export {
6464
inboundFiltersIntegration,
6565
linkedErrorsIntegration,
6666
requestDataIntegration,
67+
zodErrorsIntegration,
6768
SEMANTIC_ATTRIBUTE_SENTRY_OP,
6869
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
6970
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,

0 commit comments

Comments
 (0)