Skip to content

Commit

Permalink
feat(integrations): Add zod integration (#11144)
Browse files Browse the repository at this point in the history
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 <francesco.novy@sentry.io>
  • Loading branch information
scttcper and mydea committed May 2, 2024
1 parent 7e6c23e commit eadcac5
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 0 deletions.
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/browser/src/index.ts
Expand Up @@ -64,6 +64,7 @@ export {
setHttpStatus,
makeMultiplexedTransport,
moduleMetadataIntegration,
zodErrorsIntegration,
} from '@sentry/core';
export type { Span } from '@sentry/types';
export { makeBrowserOfflineTransport } from './transports/offline';
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 @@ -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';
Expand Down
119 changes: 119 additions & 0 deletions 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<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 errorKeyMap = new Set<string | number | symbol>();
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);
100 changes: 100 additions & 0 deletions 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,
},
],
});
});
});
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Expand Up @@ -67,6 +67,7 @@ export {
extraErrorDataIntegration,
rewriteFramesIntegration,
sessionTimingIntegration,
zodErrorsIntegration,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
Expand Down
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/vercel-edge/src/index.ts
Expand Up @@ -64,6 +64,7 @@ export {
inboundFiltersIntegration,
linkedErrorsIntegration,
requestDataIntegration,
zodErrorsIntegration,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
Expand Down

0 comments on commit eadcac5

Please sign in to comment.