Parcourir la source

chore(sentry-plugin): Add sentry plugin

Michael Bromley il y a 2 ans
Parent
commit
cde0a46a8d

+ 3 - 0
packages/sentry-plugin/.gitignore

@@ -0,0 +1,3 @@
+yarn-error.log
+lib
+e2e/__data__/*.sqlite

+ 7 - 0
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/)

+ 4 - 0
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';

+ 28 - 0
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"
+    }
+}

+ 32 - 0
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;
+        }
+    }
+}

+ 14 - 0
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
+    }
+`;

+ 11 - 0
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');
+    }
+}

+ 3 - 0
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';

+ 58 - 0
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<any>): Promise<GraphQLRequestListener<any>> {
+        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();
+                            };
+                        }
+                    },
+                };
+            },
+        };
+    }
+}

+ 25 - 0
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();
+    }
+}

+ 144 - 0
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;
+    }
+}

+ 27 - 0
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<GqlContextType>() === '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);
+    }
+}

+ 40 - 0
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);
+    }
+}

+ 26 - 0
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;
+    }
+}

+ 9 - 0
packages/sentry-plugin/tsconfig.build.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "files": [
+    "./index.ts"
+  ]
+}

+ 10 - 0
packages/sentry-plugin/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "removeComments": false,
+    "noLib": false,
+    "skipLibCheck": true,
+    "sourceMap": true
+  }
+}

+ 1 - 0
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');

+ 4 - 0
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/],

+ 40 - 0
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"