Skip to content

feat(integrations): Add zod integration #11144

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

Merged
merged 9 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading