From 6d70540cdc8df45cd68b013bc6879be916b97313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 4 Mar 2024 16:31:15 +0100 Subject: [PATCH] Add sentry tracing (#4279) * Add sentry tracign * Improve Sentry loggin --- .../hooks/__mocks__/useFindOneRecord.ts | 2 +- .../hooks/useGenerateFindOneRecordQuery.ts | 5 +- .../__stories__/RecordShowPage.stories.tsx | 2 +- ...ngsAccountsEmailsInboxSettings.stories.tsx | 2 +- ...ettingsDevelopersApiKeysDetail.stories.tsx | 2 +- ...ttingsDevelopersWebhooksDetail.stories.tsx | 2 +- .../graphql-config/graphql-config.service.ts | 38 +++++++++---- .../drivers/sentry.driver.ts | 22 +++---- .../exception-handler-user.interface.ts | 6 +- .../integrations/tracing/useSentryTracing.ts | 57 +++++++++++++++++++ packages/twenty-server/src/main.ts | 6 ++ 11 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 packages/twenty-server/src/integrations/tracing/useSentryTracing.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts index d5695be62..1da25c843 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts @@ -3,7 +3,7 @@ import { gql } from '@apollo/client'; import { responseData as person } from './useUpdateOneRecord'; export const query = gql` - query FindOneperson($objectRecordId: UUID!) { + query FindOnePerson($objectRecordId: UUID!) { person(filter: { id: { eq: $objectRecordId } }) { id opportunities { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts index aee59872a..7134cfea9 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts @@ -2,6 +2,7 @@ import { gql } from '@apollo/client'; import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from '~/utils/string/capitalize'; export const useGenerateFindOneRecordQuery = () => { const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); @@ -14,7 +15,9 @@ export const useGenerateFindOneRecordQuery = () => { depth?: number; }) => { return gql` - query FindOne${objectMetadataItem.nameSingular}($objectRecordId: UUID!) { + query FindOne${capitalize( + objectMetadataItem.nameSingular, + )}($objectRecordId: UUID!) { ${objectMetadataItem.nameSingular}(filter: { id: { eq: $objectRecordId diff --git a/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx b/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx index 0c590a984..5f1a03c5d 100644 --- a/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx +++ b/packages/twenty-front/src/pages/object-record/__stories__/RecordShowPage.stories.tsx @@ -26,7 +26,7 @@ const meta: Meta = { parameters: { msw: { handlers: [ - graphql.query('FindOneperson', () => { + graphql.query('FindOnePerson', () => { return HttpResponse.json({ data: { person: mockedPeopleData[0], diff --git a/packages/twenty-front/src/pages/settings/accounts/__stories__/SettingsAccountsEmailsInboxSettings.stories.tsx b/packages/twenty-front/src/pages/settings/accounts/__stories__/SettingsAccountsEmailsInboxSettings.stories.tsx index e3949377e..d508072dd 100644 --- a/packages/twenty-front/src/pages/settings/accounts/__stories__/SettingsAccountsEmailsInboxSettings.stories.tsx +++ b/packages/twenty-front/src/pages/settings/accounts/__stories__/SettingsAccountsEmailsInboxSettings.stories.tsx @@ -21,7 +21,7 @@ const meta: Meta = { layout: 'fullscreen', msw: { handlers: [ - graphql.query('FindOnemessageChannel', () => { + graphql.query('FindOneMessageChannel', () => { return HttpResponse.json({ data: { messageChannel: { diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersApiKeysDetail.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersApiKeysDetail.stories.tsx index f53731221..cb5902051 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersApiKeysDetail.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersApiKeysDetail.stories.tsx @@ -23,7 +23,7 @@ const meta: Meta = { msw: { handlers: [ ...graphqlMocks.handlers, - graphql.query('FindOneapiKey', () => { + graphql.query('FindOneApiKey', () => { return HttpResponse.json({ data: { apiKey: { diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersWebhooksDetail.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersWebhooksDetail.stories.tsx index 38516ed38..3ea47b551 100644 --- a/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersWebhooksDetail.stories.tsx +++ b/packages/twenty-front/src/pages/settings/developers/__stories__/SettingsDevelopersWebhooksDetail.stories.tsx @@ -20,7 +20,7 @@ const meta: Meta = { parameters: { msw: { handlers: [ - graphql.query('FindOnewebhook', () => { + graphql.query('FindOneWebhook', () => { return HttpResponse.json({ data: { webhook: { diff --git a/packages/twenty-server/src/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/graphql-config/graphql-config.service.ts index f999ee639..138d7771c 100644 --- a/packages/twenty-server/src/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/graphql-config/graphql-config.service.ts @@ -10,6 +10,7 @@ import { GraphQLSchema, GraphQLError } from 'graphql'; import GraphQLJSON from 'graphql-type-json'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { GraphQLSchemaWithContext, YogaInitialContext } from 'graphql-yoga'; +import * as Sentry from '@sentry/node'; import { TokenService } from 'src/core/auth/services/token.service'; import { CoreModule } from 'src/core/core.module'; @@ -23,6 +24,7 @@ import { useExceptionHandler } from 'src/integrations/exception-handler/hooks/us import { User } from 'src/core/user/user.entity'; import { useThrottler } from 'src/integrations/throttler/hooks/use-throttler'; import { JwtData } from 'src/core/auth/types/jwt-data.type'; +import { useSentryTracing } from 'src/integrations/tracing/useSentryTracing'; import { CreateContextFactory } from './factories/create-context.factory'; @@ -45,12 +47,30 @@ export class GraphQLConfigService createGqlOptions(): YogaDriverConfig { const isDebugMode = this.environmentService.isDebugMode(); + const plugins = [ + useThrottler({ + ttl: this.environmentService.getApiRateLimitingTtl(), + limit: this.environmentService.getApiRateLimitingLimit(), + identifyFn: (context) => { + return context.user?.id ?? context.req.ip ?? 'anonymous'; + }, + }), + useExceptionHandler({ + exceptionHandlerService: this.exceptionHandlerService, + }), + ]; + + if (Sentry.isInitialized()) { + plugins.push(useSentryTracing()); + } + const config: YogaDriverConfig = { context: (context) => this.createContextFactory.create(context), autoSchemaFile: true, include: [CoreModule], conditionalSchema: async (context) => { let user: User | undefined; + let workspace: Workspace | undefined; try { if (!this.tokenService.isTokenPresent(context.req)) { @@ -60,6 +80,7 @@ export class GraphQLConfigService const data = await this.tokenService.validateToken(context.req); user = data.user; + workspace = data.workspace; return await this.createSchema(context, data); } catch (error) { @@ -95,24 +116,17 @@ export class GraphQLConfigService ? { id: user.id, email: user.email, + firstName: user.firstName, + lastName: user.lastName, + workspaceId: workspace?.id, + workspaceDisplayName: workspace?.displayName, } : undefined, ); } }, resolvers: { JSON: GraphQLJSON }, - plugins: [ - useThrottler({ - ttl: this.environmentService.getApiRateLimitingTtl(), - limit: this.environmentService.getApiRateLimitingLimit(), - identifyFn: (context) => { - return context.user?.id ?? context.req.ip ?? 'anonymous'; - }, - }), - useExceptionHandler({ - exceptionHandlerService: this.exceptionHandlerService, - }), - ], + plugins: plugins, }; if (isDebugMode) { diff --git a/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts b/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts index a843fb27b..22b947248 100644 --- a/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts +++ b/packages/twenty-server/src/integrations/exception-handler/drivers/sentry.driver.ts @@ -16,18 +16,14 @@ export class ExceptionHandlerSentryDriver Sentry.init({ dsn: options.dsn, integrations: [ - // enable HTTP calls tracing new Sentry.Integrations.Http({ tracing: true }), - // enable Express.js middleware tracing new Sentry.Integrations.Express({ app: options.serverInstance }), new Sentry.Integrations.GraphQL(), - new Sentry.Integrations.Postgres({ - usePgNative: true, - }), + new Sentry.Integrations.Postgres(), new ProfilingIntegration(), ], - tracesSampleRate: 1.0, - profilesSampleRate: 1.0, + tracesSampleRate: 1, + profilesSampleRate: 0.05, environment: options.debug ? 'development' : 'production', debug: options.debug, }); @@ -52,9 +48,11 @@ export class ExceptionHandlerSentryDriver if (options?.user) { scope.setUser({ id: options.user.id, - ip_address: options.user.ipAddress, email: options.user.email, - username: options.user.username, + firstName: options.user.firstName, + lastName: options.user.lastName, + workspaceId: options.user.workspaceId, + workspaceDisplayName: options.user.workspaceDisplayName, }); } @@ -98,9 +96,11 @@ export class ExceptionHandlerSentryDriver if (user) { scope.setUser({ id: user.id, - ip_address: user.ipAddress, email: user.email, - username: user.username, + firstName: user.firstName, + lastName: user.lastName, + workspaceId: user.workspaceId, + workspaceDisplayName: user.workspaceDisplayName, }); } diff --git a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts index eec20ddfe..ddfc61033 100644 --- a/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts +++ b/packages/twenty-server/src/integrations/exception-handler/interfaces/exception-handler-user.interface.ts @@ -1,6 +1,8 @@ export interface ExceptionHandlerUser { id?: string; - ipAddress?: string; email?: string; - username?: string; + firstName?: string; + lastName?: string; + workspaceId?: string; + workspaceDisplayName?: string; } diff --git a/packages/twenty-server/src/integrations/tracing/useSentryTracing.ts b/packages/twenty-server/src/integrations/tracing/useSentryTracing.ts new file mode 100644 index 000000000..1e03b5a33 --- /dev/null +++ b/packages/twenty-server/src/integrations/tracing/useSentryTracing.ts @@ -0,0 +1,57 @@ +import * as Sentry from '@sentry/node'; +import { + handleStreamOrSingleExecutionResult, + Plugin, + getDocumentString, +} from '@envelop/core'; +import { OperationDefinitionNode, Kind, print } from 'graphql'; + +import { GraphQLContext } from 'src/graphql-config/graphql-config.service'; + +export const useSentryTracing = < + PluginContext extends GraphQLContext, +>(): Plugin => { + return { + onExecute({ args }) { + const transactionName = args.operationName || 'Anonymous Operation'; + const rootOperation = args.document.definitions.find( + (o) => o.kind === Kind.OPERATION_DEFINITION, + ) as OperationDefinitionNode; + const operationType = rootOperation.operation; + + const user = args.contextValue.user; + const workspace = args.contextValue.workspace; + const document = getDocumentString(args.document, print); + + Sentry.setTags({ + operationName: transactionName, + operation: operationType, + }); + + const scope = Sentry.getCurrentScope(); + + scope.setTransactionName(transactionName); + + if (user) { + scope.setUser({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + workspaceId: workspace?.id, + workspaceDisplayName: workspace?.displayName, + }); + } + + if (document) { + scope.setExtra('document', document); + } + + return { + onExecuteDone(payload) { + return handleStreamOrSingleExecutionResult(payload, () => {}); + }, + }; + }, + }; +}; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 8bba5ce2b..96e12cb40 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; +import * as Sentry from '@sentry/node'; import { graphqlUploadExpress } from 'graphql-upload'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; @@ -27,6 +28,11 @@ const bootstrap = async () => { // Use our logger app.useLogger(logger); + if (Sentry.isInitialized()) { + app.use(Sentry.Handlers.requestHandler()); + app.use(Sentry.Handlers.tracingHandler()); + } + // Apply validation pipes globally app.useGlobalPipes( new ValidationPipe({