diff --git a/packages/sentry-plugin/.gitignore b/packages/sentry-plugin/.gitignore new file mode 100644 index 0000000000..368c3fdfbe --- /dev/null +++ b/packages/sentry-plugin/.gitignore @@ -0,0 +1,3 @@ +yarn-error.log +lib +e2e/__data__/*.sqlite diff --git a/packages/sentry-plugin/README.md b/packages/sentry-plugin/README.md new file mode 100644 index 0000000000..ed8d95ed83 --- /dev/null +++ b/packages/sentry-plugin/README.md @@ -0,0 +1,7 @@ +# Vendure Sentry Plugin + +Integrates your Vendure server with the [Sentry](https://sentry.io/) application monitoring service. + +`npm install @vendure/sentry-plugin` + +For documentation, see [docs.vendure.io/typescript-api/core-plugins/sentry-plugin/](https://docs.vendure.io/typescript-api/core-plugins/sentry-plugin/) diff --git a/packages/sentry-plugin/index.ts b/packages/sentry-plugin/index.ts new file mode 100644 index 0000000000..fca085aec0 --- /dev/null +++ b/packages/sentry-plugin/index.ts @@ -0,0 +1,4 @@ +export * from './src/sentry-plugin'; +export * from './src/sentry.service'; +export * from './src/types'; +export * from './src/constants'; diff --git a/packages/sentry-plugin/package.json b/packages/sentry-plugin/package.json new file mode 100644 index 0000000000..38d0b17e99 --- /dev/null +++ b/packages/sentry-plugin/package.json @@ -0,0 +1,28 @@ +{ + "name": "@vendure/sentry-plugin", + "version": "2.1.4", + "license": "MIT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib/**/*" + ], + "scripts": { + "watch": "tsc -p ./tsconfig.build.json --watch", + "build": "rimraf lib && tsc -p ./tsconfig.build.json", + "lint": "eslint --fix ." + }, + "homepage": "https://www.vendure.io", + "funding": "https://github.com/sponsors/michaelbromley", + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@sentry/node": "^7.85.0" + }, + "devDependencies": { + "@vendure/common": "^2.1.4", + "@vendure/core": "^2.1.4", + "@sentry/node": "^7.85.0" + } +} diff --git a/packages/sentry-plugin/src/api/admin-test.resolver.ts b/packages/sentry-plugin/src/api/admin-test.resolver.ts new file mode 100644 index 0000000000..937f5c1c65 --- /dev/null +++ b/packages/sentry-plugin/src/api/admin-test.resolver.ts @@ -0,0 +1,32 @@ +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { Allow, Permission, UserInputError } from '@vendure/core'; + +import { SentryService } from '../sentry.service'; +import { ErrorTestService } from './error-test.service'; + +declare const a: number; + +@Resolver() +export class SentryAdminTestResolver { + constructor(private sentryService: SentryService, private errorTestService: ErrorTestService) {} + + @Allow(Permission.SuperAdmin) + @Mutation() + async createTestError(@Args() args: { errorType: string }) { + switch (args.errorType) { + case 'UNCAUGHT_ERROR': + return a / 10; + case 'THROWN_ERROR': + throw new UserInputError('SentryPlugin Test Error'); + case 'CAPTURED_ERROR': + this.sentryService.captureException(new Error('SentryPlugin Direct error')); + return true; + case 'CAPTURED_MESSAGE': + this.sentryService.captureMessage('Captured message'); + return true; + case 'DATABASE_ERROR': + await this.errorTestService.createDatabaseError(); + return true; + } + } +} diff --git a/packages/sentry-plugin/src/api/api-extensions.ts b/packages/sentry-plugin/src/api/api-extensions.ts new file mode 100644 index 0000000000..6633714597 --- /dev/null +++ b/packages/sentry-plugin/src/api/api-extensions.ts @@ -0,0 +1,14 @@ +import gql from 'graphql-tag'; + +export const testApiExtensions = gql` + enum TestErrorType { + UNCAUGHT_ERROR + THROWN_ERROR + CAPTURED_ERROR + CAPTURED_MESSAGE + DATABASE_ERROR + } + extend type Mutation { + createTestError(errorType: TestErrorType!): Boolean + } +`; diff --git a/packages/sentry-plugin/src/api/error-test.service.ts b/packages/sentry-plugin/src/api/error-test.service.ts new file mode 100644 index 0000000000..4a1f2df346 --- /dev/null +++ b/packages/sentry-plugin/src/api/error-test.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { TransactionalConnection } from '@vendure/core'; + +@Injectable() +export class ErrorTestService { + constructor(private connection: TransactionalConnection) {} + + createDatabaseError() { + return this.connection.rawConnection.query('SELECT * FROM non_existent_table'); + } +} diff --git a/packages/sentry-plugin/src/constants.ts b/packages/sentry-plugin/src/constants.ts new file mode 100644 index 0000000000..baa4e64601 --- /dev/null +++ b/packages/sentry-plugin/src/constants.ts @@ -0,0 +1,3 @@ +export const SENTRY_PLUGIN_OPTIONS = 'SENTRY_PLUGIN_OPTIONS'; +export const SENTRY_TRANSACTION_KEY = 'SENTRY_PLUGIN_TRANSACTION'; +export const loggerCtx = 'SentryPlugin'; diff --git a/packages/sentry-plugin/src/sentry-apollo-plugin.ts b/packages/sentry-plugin/src/sentry-apollo-plugin.ts new file mode 100644 index 0000000000..59deaf9fa3 --- /dev/null +++ b/packages/sentry-plugin/src/sentry-apollo-plugin.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { + ApolloServerPlugin, + GraphQLRequestListener, + GraphQLRequestContext, + GraphQLRequestContextDidEncounterErrors, +} from '@apollo/server'; +import { Transaction, setContext } from '@sentry/node'; + +import { SENTRY_TRANSACTION_KEY } from './constants'; + +/** + * Based on https://github.com/ntegral/nestjs-sentry/issues/97#issuecomment-1252446807 + */ +export class SentryApolloPlugin implements ApolloServerPlugin { + constructor(private options: { enableTracing: boolean }) {} + + async requestDidStart({ + request, + contextValue, + }: GraphQLRequestContext): Promise> { + const { enableTracing } = this.options; + const transaction: Transaction | undefined = contextValue.req[SENTRY_TRANSACTION_KEY]; + if (request.operationName) { + if (enableTracing) { + // set the transaction Name if we have named queries + transaction?.setName(request.operationName); + } + setContext('Graphql Request', { + operation_name: request.operationName, + variables: request.variables, + }); + } + + return { + // hook for transaction finished + async willSendResponse(context) { + transaction?.finish(); + }, + async executionDidStart() { + return { + // hook for each new resolver + willResolveField({ info }) { + if (enableTracing) { + const span = transaction?.startChild({ + op: 'resolver', + description: `${info.parentType.name}.${info.fieldName}`, + }); + return () => { + span?.finish(); + }; + } + }, + }; + }, + }; + } +} diff --git a/packages/sentry-plugin/src/sentry-context.middleware.ts b/packages/sentry-plugin/src/sentry-context.middleware.ts new file mode 100644 index 0000000000..21cb0b75ab --- /dev/null +++ b/packages/sentry-plugin/src/sentry-context.middleware.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +import { SENTRY_PLUGIN_OPTIONS, SENTRY_TRANSACTION_KEY } from './constants'; +import { SentryService } from './sentry.service'; +import { SentryPluginOptions } from './types'; + +@Injectable() +export class SentryContextMiddleware implements NestMiddleware { + constructor( + @Inject(SENTRY_PLUGIN_OPTIONS) private options: SentryPluginOptions, + private sentryService: SentryService, + ) {} + + use(req: Request, res: Response, next: NextFunction) { + if (this.options.enableTracing) { + const transaction = this.sentryService.startTransaction({ + op: 'resolver', + name: `GraphQLTransaction`, + }); + req[SENTRY_TRANSACTION_KEY] = transaction; + } + next(); + } +} diff --git a/packages/sentry-plugin/src/sentry-plugin.ts b/packages/sentry-plugin/src/sentry-plugin.ts new file mode 100644 index 0000000000..24818eeec2 --- /dev/null +++ b/packages/sentry-plugin/src/sentry-plugin.ts @@ -0,0 +1,144 @@ +import { MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { PluginCommonModule, VendurePlugin } from '@vendure/core'; + +import { SentryAdminTestResolver } from './api/admin-test.resolver'; +import { testApiExtensions } from './api/api-extensions'; +import { ErrorTestService } from './api/error-test.service'; +import { SENTRY_PLUGIN_OPTIONS } from './constants'; +import { SentryApolloPlugin } from './sentry-apollo-plugin'; +import { SentryContextMiddleware } from './sentry-context.middleware'; +import { SentryExceptionsFilter } from './sentry.filter'; +import { SentryService } from './sentry.service'; +import { SentryPluginOptions } from './types'; + +const SentryOptionsProvider = { + provide: SENTRY_PLUGIN_OPTIONS, + useFactory: () => SentryPlugin.options, +}; + +/** + * @description + * This plugin integrates the [Sentry](https://sentry.io) error tracking & performance monitoring + * service with your Vendure server. In addition to capturing errors, it also provides built-in + * support for [tracing](https://docs.sentry.io/product/sentry-basics/concepts/tracing/) as well as + * enriching your Sentry events with additional context about the request. + * + * ## Pre-requisites + * + * This plugin depends on access to Sentry, which can be self-hosted or used as a cloud service. + * + * If using the hosted SaaS option, you must have a Sentry account and a project set up ([sign up here](https://sentry.io/signup/)). When setting up your project, + * select the "Node.js" platform and no framework. + * + * Once set up, you will be given a [Data Source Name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) + * which you will need to provide to the plugin. + * + * ## Installation + * + * Install this plugin as well as the `@sentry/node` package: + * + * ```sh + * npm install --save @vendure/sentry-plugin @sentry/node + * ``` + * + * ## Configuration + * + * Before using the plugin, you must configure it with the DSN provided by Sentry: + * + * ```ts + * import { VendureConfig } from '\@vendure/core'; + * import { SentryPlugin } from '\@vendure/sentry-plugin'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * // ... + * // highlight-start + * SentryPlugin.init({ + * dsn: process.env.SENTRY_DSN, + * // Optional configuration + * includeErrorTestMutation: true, + * enableTracing: true, + * // you can also pass in any of the options from @sentry/node + * }), + * // highlight-end + * ], + * }; + *``` + * + * ## Tracing + * + * This plugin includes built-in support for [tracing](https://docs.sentry.io/product/sentry-basics/concepts/tracing/), which allows you to see the performance of your + * GraphQL resolvers in the Sentry dashboard. To enable tracing, set the `enableTracing` option to `true` as shown above. + * + * ## Instrumenting your own code + * + * You may want to add your own custom spans to your code. To do so, you can use the `Sentry` object + * just as you would in any Node application. For example: + * + * ```ts + * import * as Sentry from "\@sentry/node"; + * + * export class MyService { + * async myMethod() { + * Sentry.setContext('My Custom Context,{ + * key: 'value', + * }); + * } + * } + * ``` + * + * ## Error test mutation + * + * To test whether your Sentry configuration is working correctly, you can set the `includeErrorTestMutation` option to `true`. This will add a mutation to the Admin API + * which will throw an error of the type specified in the `errorType` argument. For example: + * + * ```gql + * mutation CreateTestError { + * createTestError(errorType: DATABASE_ERROR) + * } + * ``` + * + * You should then be able to see the error in your Sentry dashboard (it may take a couple of minutes to appear). + * + * @docsCategory core plugins/SentryPlugin + */ +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [ + SentryOptionsProvider, + SentryService, + ErrorTestService, + { + provide: APP_FILTER, + useClass: SentryExceptionsFilter, + }, + ], + configuration: config => { + config.apiOptions.apolloServerPlugins.push( + new SentryApolloPlugin({ + enableTracing: !!SentryPlugin.options.enableTracing, + }), + ); + return config; + }, + adminApiExtensions: { + schema: () => (SentryPlugin.options.includeErrorTestMutation ? testApiExtensions : undefined), + resolvers: () => (SentryPlugin.options.includeErrorTestMutation ? [SentryAdminTestResolver] : []), + }, + exports: [SentryService], + compatibility: '^2.0.0', +}) +export class SentryPlugin implements NestModule { + static options: SentryPluginOptions = {} as any; + + configure(consumer: MiddlewareConsumer): any { + consumer.apply(SentryContextMiddleware).forRoutes('*'); + } + + static init(options: SentryPluginOptions) { + this.options = options; + return this; + } +} diff --git a/packages/sentry-plugin/src/sentry.filter.ts b/packages/sentry-plugin/src/sentry.filter.ts new file mode 100644 index 0000000000..cd248842ec --- /dev/null +++ b/packages/sentry-plugin/src/sentry.filter.ts @@ -0,0 +1,27 @@ +import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; +import { Catch, ExecutionContext } from '@nestjs/common'; +import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; +import { setContext } from '@sentry/node'; + +import { SentryService } from './sentry.service'; + +@Catch() +export class SentryExceptionsFilter implements ExceptionFilter { + constructor(private readonly sentryService: SentryService) {} + + catch(exception: Error, host: ArgumentsHost): void { + if (host.getType() === 'graphql') { + const gqlContext = GqlExecutionContext.create(host as ExecutionContext); + const info = gqlContext.getInfo(); + setContext('GraphQL Error Context', { + fieldName: info.fieldName, + path: info.path, + }); + } + const variables = (exception as any).variables; + if (variables) { + setContext('GraphQL Error Variables', variables); + } + this.sentryService.captureException(exception); + } +} diff --git a/packages/sentry-plugin/src/sentry.service.ts b/packages/sentry-plugin/src/sentry.service.ts new file mode 100644 index 0000000000..307fc339d3 --- /dev/null +++ b/packages/sentry-plugin/src/sentry.service.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; +import { CaptureContext, TransactionContext } from '@sentry/types'; + +import { SENTRY_PLUGIN_OPTIONS } from './constants'; +import { SentryPluginOptions } from './types'; + +@Injectable() +export class SentryService implements OnApplicationBootstrap, OnApplicationShutdown { + constructor(@Inject(SENTRY_PLUGIN_OPTIONS) private options: SentryPluginOptions) {} + + onApplicationBootstrap(): any { + const integrations = this.options.integrations ?? [ + new Sentry.Integrations.Http({ tracing: true }), + ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(), + ]; + Sentry.init({ + ...this.options, + tracesSampleRate: this.options.tracesSampleRate ?? 1.0, + integrations, + dsn: this.options.dsn, + }); + } + + onApplicationShutdown() { + return Sentry.close(); + } + + captureException(exception: Error) { + Sentry.captureException(exception); + } + + captureMessage(message: string, captureContext?: CaptureContext) { + Sentry.captureMessage(message, captureContext); + } + + startTransaction(context: TransactionContext) { + return Sentry.startTransaction(context); + } +} diff --git a/packages/sentry-plugin/src/types.ts b/packages/sentry-plugin/src/types.ts new file mode 100644 index 0000000000..b31eb99ba1 --- /dev/null +++ b/packages/sentry-plugin/src/types.ts @@ -0,0 +1,26 @@ +import { Transaction } from '@sentry/node'; +import { NodeOptions } from '@sentry/node/types/types'; + +import { SENTRY_TRANSACTION_KEY } from './constants'; + +/** + * @description + * Configuration options for the {@link SentryPlugin}. + * + * @docsCategory core plugins/SentryPlugin + */ +export interface SentryPluginOptions extends NodeOptions { + /** + * @description + * The [Data Source Name](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) for your Sentry instance. + */ + dsn: string; + enableTracing?: boolean; + includeErrorTestMutation?: boolean; +} + +declare module 'express' { + interface Request { + [SENTRY_TRANSACTION_KEY]: Transaction | undefined; + } +} diff --git a/packages/sentry-plugin/tsconfig.build.json b/packages/sentry-plugin/tsconfig.build.json new file mode 100644 index 0000000000..821b637ae7 --- /dev/null +++ b/packages/sentry-plugin/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./lib" + }, + "files": [ + "./index.ts" + ] +} diff --git a/packages/sentry-plugin/tsconfig.json b/packages/sentry-plugin/tsconfig.json new file mode 100644 index 0000000000..f343c8c4b3 --- /dev/null +++ b/packages/sentry-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "removeComments": false, + "noLib": false, + "skipLibCheck": true, + "sourceMap": true + } +} diff --git a/scripts/changelogs/generate-changelog.ts b/scripts/changelogs/generate-changelog.ts index a257d31230..b9dbbea933 100644 --- a/scripts/changelogs/generate-changelog.ts +++ b/scripts/changelogs/generate-changelog.ts @@ -36,6 +36,7 @@ const VALID_SCOPES: string[] = [ 'ui-devkit', 'harden-plugin', 'stellate-plugin', + 'sentry-plugin', ]; const mainTemplate = fs.readFileSync(path.join(__dirname, 'template.hbs'), 'utf-8'); diff --git a/scripts/docs/generate-typescript-docs.ts b/scripts/docs/generate-typescript-docs.ts index 2de072e5c4..5e3728522f 100644 --- a/scripts/docs/generate-typescript-docs.ts +++ b/scripts/docs/generate-typescript-docs.ts @@ -53,6 +53,10 @@ const sections: DocsSectionConfig[] = [ sourceDirs: ['packages/stellate-plugin/src/'], outputPath: '', }, + { + sourceDirs: ['packages/sentry-plugin/src/'], + outputPath: '', + }, { sourceDirs: ['packages/admin-ui/src/lib/', 'packages/ui-devkit/src/'], exclude: [/generated-types/], diff --git a/yarn.lock b/yarn.lock index 9e117b0907..2d285a9514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4335,6 +4335,46 @@ "@angular-devkit/schematics" "16.2.0" jsonc-parser "3.2.0" +"@sentry-internal/tracing@7.85.0": + version "7.85.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.85.0.tgz#1b4781a61e1e43badeff826cf40abe33dd760f1d" + integrity sha512-p3YMUwkPCy2su9cm/3+7QYR4RiMI0+07DU1BZtht9NLTzY2O87/yvUbn1v2yHR3vJQTy/+7N0ud9/mPBFznRQQ== + dependencies: + "@sentry/core" "7.85.0" + "@sentry/types" "7.85.0" + "@sentry/utils" "7.85.0" + +"@sentry/core@7.85.0": + version "7.85.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.85.0.tgz#dd90d772a5f75ff674f931f59b22a3fc286d0983" + integrity sha512-DFDAc4tWmHN5IWhr7XbHCiyF1Xgb95jz8Uj/JTX9atlgodId1UIbER77qpEmH3eQGid/QBdqrlR98zCixgSbwg== + dependencies: + "@sentry/types" "7.85.0" + "@sentry/utils" "7.85.0" + +"@sentry/node@^7.85.0": + version "7.85.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.85.0.tgz#cf4e6022b5cd1f3fb007186c5e04427b108ebe1d" + integrity sha512-uiBtRW9G017NHoCXBlK3ttkTwHXLFyI8ndHpaObtyajKTv3ptGIThVEn7DuK7Pwor//RjwjSEEOa7WDK+FdMVQ== + dependencies: + "@sentry-internal/tracing" "7.85.0" + "@sentry/core" "7.85.0" + "@sentry/types" "7.85.0" + "@sentry/utils" "7.85.0" + https-proxy-agent "^5.0.0" + +"@sentry/types@7.85.0": + version "7.85.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.85.0.tgz#648488b90f958ca6a86922cc5d26004853410ba6" + integrity sha512-R5jR4XkK5tBU2jDiPdSVqzkmjYRr666bcGaFGUHB/xDQCjPsjk+pEmCCL+vpuWoaZmQJUE1hVU7rgnVX81w8zg== + +"@sentry/utils@7.85.0": + version "7.85.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.85.0.tgz#b84467fd07bc2ef09fdf382ddcdcdc3f5b0d78b0" + integrity sha512-JZ7seNOLvhjAQ8GeB3GYknPQJkuhF88xAYOaESZP3xPOWBMFUN+IO4RqjMqMLFDniOwsVQS7GB/MfP+hxufieg== + dependencies: + "@sentry/types" "7.85.0" + "@sigstore/bundle@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-1.1.0.tgz#17f8d813b09348b16eeed66a8cf1c3d6bd3d04f1"