Skip to content

Commit

Permalink
chore(sentry-plugin): Add sentry plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Dec 5, 2023
1 parent 2254576 commit cde0a46
Show file tree
Hide file tree
Showing 19 changed files with 486 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/sentry-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
yarn-error.log
lib
e2e/__data__/*.sqlite
7 changes: 7 additions & 0 deletions packages/sentry-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/sentry-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './src/sentry-plugin';
export * from './src/sentry.service';
export * from './src/types';
export * from './src/constants';
28 changes: 28 additions & 0 deletions packages/sentry-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions packages/sentry-plugin/src/api/admin-test.resolver.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions packages/sentry-plugin/src/api/api-extensions.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions packages/sentry-plugin/src/api/error-test.service.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions packages/sentry-plugin/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 58 additions & 0 deletions packages/sentry-plugin/src/sentry-apollo-plugin.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions packages/sentry-plugin/src/sentry-context.middleware.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 144 additions & 0 deletions packages/sentry-plugin/src/sentry-plugin.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions packages/sentry-plugin/src/sentry.filter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit cde0a46

Please sign in to comment.