diff --git a/packages/twenty-server/src/engine/core-modules/exception-handler/__tests__/sentry.driver.spec.ts b/packages/twenty-server/src/engine/core-modules/exception-handler/__tests__/sentry.driver.spec.ts new file mode 100644 index 00000000000..0a8fefd3120 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/exception-handler/__tests__/sentry.driver.spec.ts @@ -0,0 +1,65 @@ +import * as Sentry from '@sentry/node'; + +import { ExceptionHandlerSentryDriver } from 'src/engine/core-modules/exception-handler/drivers/sentry.driver'; + +const setExtraMock = jest.fn(); +const setTagMock = jest.fn(); +const setUserMock = jest.fn(); +const setContextMock = jest.fn(); +const setFingerprintMock = jest.fn(); +const addBreadcrumbMock = jest.fn(); +const captureExceptionMock = jest.fn(() => 'event-id'); + +jest.mock('@sentry/node', () => ({ + withScope: (callback: (scope: unknown) => void) => { + callback({ + setExtra: setExtraMock, + setTag: setTagMock, + setUser: setUserMock, + setContext: setContextMock, + setFingerprint: setFingerprintMock, + addBreadcrumb: addBreadcrumbMock, + }); + }, + captureException: (...args: unknown[]) => captureExceptionMock(...args), +})); + +describe('ExceptionHandlerSentryDriver', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add workspace tags when workspace details are available', () => { + const driver = new ExceptionHandlerSentryDriver(); + + driver.captureExceptions([new Error('boom')], { + workspace: { + id: 'workspace-id', + activationStatus: 'ACTIVE', + version: '2.8', + metadataVersion: 42, + }, + }); + + expect(setTagMock).toHaveBeenCalledWith('workspaceId', 'workspace-id'); + expect(setTagMock).toHaveBeenCalledWith( + 'workspaceActivationStatus', + 'ACTIVE', + ); + expect(setTagMock).toHaveBeenCalledWith('workspaceVersion', '2.8'); + expect(setTagMock).toHaveBeenCalledWith('workspaceMetadataVersion', '42'); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + it('should not add workspace tags when workspace details are missing', () => { + const driver = new ExceptionHandlerSentryDriver(); + + driver.captureExceptions([new Error('boom')]); + + expect(setTagMock).not.toHaveBeenCalledWith( + 'workspaceMetadataVersion', + expect.anything(), + ); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/exception-handler/drivers/sentry.driver.ts b/packages/twenty-server/src/engine/core-modules/exception-handler/drivers/sentry.driver.ts index 07209dc9ded..d3bdbdfb643 100644 --- a/packages/twenty-server/src/engine/core-modules/exception-handler/drivers/sentry.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/exception-handler/drivers/sentry.driver.ts @@ -32,6 +32,28 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter if (options?.workspace) { scope.setExtra('workspace', options.workspace); + + if (isDefined(options.workspace.id)) { + scope.setTag('workspaceId', options.workspace.id); + } + + if (isDefined(options.workspace.activationStatus)) { + scope.setTag( + 'workspaceActivationStatus', + options.workspace.activationStatus, + ); + } + + if (isDefined(options.workspace.version)) { + scope.setTag('workspaceVersion', options.workspace.version); + } + + if (isDefined(options.workspace.metadataVersion)) { + scope.setTag( + 'workspaceMetadataVersion', + `${options.workspace.metadataVersion}`, + ); + } } if (options?.additionalData) { diff --git a/packages/twenty-server/src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface.ts b/packages/twenty-server/src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface.ts index 9614a17d624..2d64d10e40d 100644 --- a/packages/twenty-server/src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface.ts @@ -3,4 +3,6 @@ export interface ExceptionHandlerWorkspace { displayName?: string; activationStatus?: string; createdAt?: string; + metadataVersion?: number; + version?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts index f068be4cd35..55f95774010 100644 --- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts @@ -37,6 +37,7 @@ import { translateUserFriendlyMessageDescriptors } from 'src/engine/core-modules const DEFAULT_EVENT_ID_KEY = 'exceptionEventId'; const SCHEMA_VERSION_HEADER = 'x-schema-version'; const SCHEMA_MISMATCH_ERROR = 'Schema version mismatch.'; +const SCHEMA_MISMATCH_CODE = 'SCHEMA_VERSION_MISMATCH'; const APP_VERSION_HEADER = 'x-app-version'; const APP_VERSION_MISMATCH_ERROR = 'App version mismatch.'; const APP_VERSION_MISMATCH_CODE = 'APP_VERSION_MISMATCH'; @@ -76,6 +77,8 @@ export const useGraphQLErrorHandlerHook = < displayName: req.workspace.displayName, createdAt: req.workspace.createdAt ?? null, activationStatus: req.workspace.activationStatus, + metadataVersion: req.workspace.metadataVersion, + version: req.workspace.version, }; } @@ -204,6 +207,14 @@ export const useGraphQLErrorHandlerHook = < document, user, workspace: workspaceInfo, + additionalData: { + requestSchemaVersion: + args.contextValue.req.headers[SCHEMA_VERSION_HEADER], + workspaceSchemaVersion: + args.contextValue.req.workspaceMetadataVersion, + requestAppVersion: + args.contextValue.req.headers[APP_VERSION_HEADER], + }, }, ); @@ -288,6 +299,7 @@ export const useGraphQLErrorHandlerHook = < throw new GraphQLError(SCHEMA_MISMATCH_ERROR, { extensions: { + code: SCHEMA_MISMATCH_CODE, userFriendlyMessage: i18n._( msg`Your workspace has been updated with a new data model. Please refresh the page.`, ),