diff --git a/packages/backend/package.json b/packages/backend/package.json index f087573727..a3e2a4dbde 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -27,6 +27,8 @@ "@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/load": "^7.5.2", "@rudderstack/rudder-sdk-node": "^1.1.2", + "@sentry/node": "^7.42.0", + "@sentry/tracing": "^7.42.0", "@types/luxon": "^2.3.1", "ajv-formats": "^2.1.1", "axios": "0.24.0", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e5413b01f0..e12d1c0644 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,9 +1,12 @@ import createError from 'http-errors'; import express from 'express'; -import appConfig from './config/app'; import cors from 'cors'; + +import { IRequest } from '@automatisch/types'; +import appConfig from './config/app'; import corsOptions from './config/cors-options'; import morgan from './helpers/morgan'; +import * as Sentry from './helpers/sentry.ee'; import appAssetsHandler from './helpers/app-assets-handler'; import webUIHandler from './helpers/web-ui-handler'; import errorHandler from './helpers/error-handler'; @@ -14,12 +17,16 @@ import { } from './helpers/create-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler'; import router from './routes'; -import { IRequest } from '@automatisch/types'; createBullBoardHandler(serverAdapter); const app = express(); +Sentry.init(app); + +Sentry.attachRequestHandler(app); +Sentry.attachTracingHandler(app); + injectBullBoardHandler(app, serverAdapter); appAssetsHandler(app); @@ -50,6 +57,8 @@ app.use(function (req, res, next) { next(createError(404)); }); +Sentry.attachErrorHandler(app); + app.use(errorHandler); export default app; diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index efa5e8fa94..fc8ad0b4b1 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -44,6 +44,7 @@ type AppConfig = { stripeStarterPriceKey: string; stripeGrowthPriceKey: string; licenseKey: string; + sentryDsn: string; }; const host = process.env.HOST || 'localhost'; @@ -115,6 +116,7 @@ const appConfig: AppConfig = { stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY, stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY, licenseKey: process.env.LICENSE_KEY, + sentryDsn: process.env.SENTRY_DSN, }; if (!appConfig.encryptionKey) { diff --git a/packages/backend/src/controllers/stripe/webhooks.ee.ts b/packages/backend/src/controllers/stripe/webhooks.ee.ts index fedbaea34c..bd398dfe58 100644 --- a/packages/backend/src/controllers/stripe/webhooks.ee.ts +++ b/packages/backend/src/controllers/stripe/webhooks.ee.ts @@ -1,5 +1,7 @@ import { Response } from 'express'; import { IRequest } from '@automatisch/types'; + +import * as Sentry from '../../helpers/sentry.ee'; import Billing from '../../helpers/billing/index.ee'; import appConfig from '../../config/app'; import logger from '../../helpers/logger'; @@ -18,6 +20,8 @@ export default async (request: IRequest, response: Response) => { return response.sendStatus(200); } catch (error) { logger.error(`Webhook Error: ${error.message}`); + + Sentry.captureException(error); return response.sendStatus(400); } }; diff --git a/packages/backend/src/errors/base.ts b/packages/backend/src/errors/base.ts index 2e3e9863d5..e72fb6dcdf 100644 --- a/packages/backend/src/errors/base.ts +++ b/packages/backend/src/errors/base.ts @@ -2,6 +2,7 @@ import { IJSONObject } from '@automatisch/types'; export default class BaseError extends Error { details = {}; + statusCode?: number; constructor(error?: string | IJSONObject) { let computedError: Record; diff --git a/packages/backend/src/errors/quote-exceeded.ts b/packages/backend/src/errors/quote-exceeded.ts new file mode 100644 index 0000000000..6a3239d087 --- /dev/null +++ b/packages/backend/src/errors/quote-exceeded.ts @@ -0,0 +1,9 @@ +import BaseError from './base'; + +export default class QuotaExceededError extends BaseError { + constructor(error = 'The allowed task quota has been exhausted!') { + super(error); + + this.statusCode = 422; + } +} diff --git a/packages/backend/src/helpers/error-handler.ts b/packages/backend/src/helpers/error-handler.ts index a2871f1e76..6c54c65798 100644 --- a/packages/backend/src/helpers/error-handler.ts +++ b/packages/backend/src/helpers/error-handler.ts @@ -1,16 +1,15 @@ -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import logger from './logger'; -type Error = { - message: string; -}; +import BaseError from '../errors/base'; -const errorHandler = (err: Error, req: Request, res: Response): void => { +// Do not remove `next` argument as the function signature will not fit for an error handler middleware +const errorHandler = (err: BaseError, req: Request, res: Response, next: NextFunction): void => { if (err.message === 'Not Found') { res.status(404).end(); } else { logger.error(err.message); - res.status(500).end(); + res.status(err.statusCode || 500).send(err.message); } }; diff --git a/packages/backend/src/helpers/graphql-instance.ts b/packages/backend/src/helpers/graphql-instance.ts index cace804346..34c92942ee 100644 --- a/packages/backend/src/helpers/graphql-instance.ts +++ b/packages/backend/src/helpers/graphql-instance.ts @@ -4,8 +4,10 @@ import { loadSchemaSync } from '@graphql-tools/load'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { addResolversToSchema } from '@graphql-tools/schema'; import { applyMiddleware } from 'graphql-middleware'; + import logger from '../helpers/logger'; import authentication from '../helpers/authentication'; +import * as Sentry from '../helpers/sentry.ee'; import resolvers from '../graphql/resolvers'; import HttpError from '../errors/http'; @@ -28,6 +30,15 @@ const graphQLInstance = graphqlHTTP({ delete (error.originalError as HttpError).response; } + Sentry.captureException(error, { + tags: { graphql: true }, + extra: { + source: error.source?.body, + positions: error.positions, + path: error.path + } + }) + return error; }, }); diff --git a/packages/backend/src/helpers/sentry.ee.ts b/packages/backend/src/helpers/sentry.ee.ts new file mode 100644 index 0000000000..8369110e87 --- /dev/null +++ b/packages/backend/src/helpers/sentry.ee.ts @@ -0,0 +1,51 @@ +import { Express } from 'express'; +import * as Sentry from '@sentry/node'; +import type { CaptureContext } from '@sentry/types'; +import * as Tracing from '@sentry/tracing'; + +import appConfig from '../config/app'; + +export function init(app?: Express) { + if (!appConfig.isCloud) return; + + return Sentry.init({ + enabled: !!appConfig.sentryDsn, + dsn: appConfig.sentryDsn, + integrations: [ + app && new Sentry.Integrations.Http({ tracing: true }), + app && new Tracing.Integrations.Express({ app }), + app && new Tracing.Integrations.GraphQL(), + ].filter(Boolean), + tracesSampleRate: 1.0, + }); +} + + +export function attachRequestHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.requestHandler()); +} + +export function attachTracingHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.tracingHandler()); +} + +export function attachErrorHandler(app: Express) { + if (!appConfig.isCloud) return; + + app.use(Sentry.Handlers.errorHandler({ + shouldHandleError() { + // TODO: narrow down the captured errors in time as we receive samples + return true; + } + })); +} + +export function captureException(exception: any, captureContext?: CaptureContext) { + if (!appConfig.isCloud) return; + + return Sentry.captureException(exception, captureContext); +} diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 90abceef91..6eaab50f50 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -7,6 +7,7 @@ import Step from './step'; import User from './user'; import Execution from './execution'; import Telemetry from '../helpers/telemetry'; +import QuotaExceededError from '../errors/quote-exceeded'; class Flow extends Base { id!: string; @@ -152,7 +153,7 @@ class Flow extends Base { const hasExceeded = await this.checkIfQuotaExceeded(); if (hasExceeded) { - throw new Error('The allowed task quota has been exhausted!'); + throw new QuotaExceededError(); } return this; diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index 98191a0a7b..31e51297c3 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -21,7 +21,7 @@ const exposeError = (handler: RequestHandler) => async (req: IRequest, res: Resp try { await handler(req, res, next); } catch (err) { - res.status(422).send(err); + next(err); } } diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts index 9e0d564685..141ac78bb1 100644 --- a/packages/backend/src/worker.ts +++ b/packages/backend/src/worker.ts @@ -1,3 +1,7 @@ +import * as Sentry from './helpers/sentry.ee'; + +Sentry.init(); + import './config/orm'; import './helpers/check-worker-readiness'; import './workers/flow'; diff --git a/packages/backend/src/workers/action.ts b/packages/backend/src/workers/action.ts index 22d1169e4e..1cf1623790 100644 --- a/packages/backend/src/workers/action.ts +++ b/packages/backend/src/workers/action.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import Step from '../models/step'; @@ -65,6 +67,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/delete-user.ee.ts b/packages/backend/src/workers/delete-user.ee.ts index 887149a2aa..12a0118ff7 100644 --- a/packages/backend/src/workers/delete-user.ee.ts +++ b/packages/backend/src/workers/delete-user.ee.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import User from '../models/user'; @@ -37,6 +39,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/email.ts b/packages/backend/src/workers/email.ts index 953f9c41ed..ad8aba5778 100644 --- a/packages/backend/src/workers/email.ts +++ b/packages/backend/src/workers/email.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import mailer from '../helpers/mailer.ee'; @@ -30,6 +32,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - ${job.data.subject} email to ${job.data.email} has failed to send with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/flow.ts b/packages/backend/src/workers/flow.ts index 04d4bf002c..f8b2b01bdf 100644 --- a/packages/backend/src/workers/flow.ts +++ b/packages/backend/src/workers/flow.ts @@ -1,4 +1,6 @@ import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; import triggerQueue from '../queues/trigger'; @@ -65,6 +67,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/backend/src/workers/trigger.ts b/packages/backend/src/workers/trigger.ts index a74923288c..953bbf3308 100644 --- a/packages/backend/src/workers/trigger.ts +++ b/packages/backend/src/workers/trigger.ts @@ -1,7 +1,9 @@ import { Worker } from 'bullmq'; + +import { IJSONObject, ITriggerItem } from '@automatisch/types'; +import * as Sentry from '../helpers/sentry.ee'; import redisConfig from '../config/redis'; import logger from '../helpers/logger'; -import { IJSONObject, ITriggerItem } from '@automatisch/types'; import actionQueue from '../queues/action'; import Step from '../models/step'; import { processTrigger } from '../services/trigger'; @@ -51,6 +53,12 @@ worker.on('failed', (job, err) => { logger.info( `JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message}` ); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + } + }); }); process.on('SIGTERM', async () => { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 145c68a695..40ecea8eb1 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -333,5 +333,6 @@ declare module 'axios' { export interface IRequest extends Request { rawBody?: Buffer; + currentUser?: IUser; } diff --git a/yarn.lock b/yarn.lock index 742a7a0ba1..a13a021b67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3415,6 +3415,51 @@ component-type "^1.2.1" join-component "^1.1.0" +"@sentry/core@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.42.0.tgz#3333a1b868e8e69b6fbc8e5a9e9281be49321ac7" + integrity sha512-vNcTyoQz5kUXo5vMGDyc5BJMO0UugPvMfYMQVxqt/BuDNR30LVhY+DL2tW1DFZDvRvyn5At+H7kSTj6GFrANXQ== + dependencies: + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + tslib "^1.9.3" + +"@sentry/node@^7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.42.0.tgz#62b31f5b5b8ffb8f2f917deb143e27935b357409" + integrity sha512-mmpVSDeoM5aEbKOMq3Wt54wAvH53bkivhRh3Ip+R7Uj3aOKkcVJST2XlbghHgoYQXTWz+pl475EVyODWgY9QYg== + dependencies: + "@sentry/core" "7.42.0" + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + cookie "^0.4.1" + https-proxy-agent "^5.0.0" + lru_map "^0.3.3" + tslib "^1.9.3" + +"@sentry/tracing@^7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.42.0.tgz#bcdac21e1cb5f786465e6252625bd4bf0736e631" + integrity sha512-0veGu3Ntweuj1pwWrJIFHmVdow4yufCreGIhsNDyrclwOjaTY3uI8iA6N62+hhtxOvqv+xueB98K1DvT5liPCQ== + dependencies: + "@sentry/core" "7.42.0" + "@sentry/types" "7.42.0" + "@sentry/utils" "7.42.0" + tslib "^1.9.3" + +"@sentry/types@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.42.0.tgz#e5019cd41a8c4a98c296e2ff28d6adab4b2eb14e" + integrity sha512-Ga0xaBIR/peuXQ88hI9a5TNY3GLNoH8jpsgPaAjAtRHkLsTx0y3AR+PrD7pUysza9QjvG+Qux01DRvLgaNKOHA== + +"@sentry/utils@7.42.0": + version "7.42.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.42.0.tgz#fcffd0404836cb56975fbef9e889a42cc55de596" + integrity sha512-cBiDZVipC+is+IVgsTQLJyZWUZQxlLZ9GarNT+XZOZ5BFh0acFtz88hO6+S7vGmhcx2aCvsdC9yb2Yf+BphK6Q== + dependencies: + "@sentry/types" "7.42.0" + tslib "^1.9.3" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -6778,7 +6823,7 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.4.2: +cookie@0.4.2, cookie@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -11703,6 +11748,11 @@ lru-cache@^7.3.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.4.0.tgz#2830a779b483e9723e20f26fa5278463c50599d8" integrity sha512-YOfuyWa/Ee+PXbDm40j9WXyJrzQUynVbgn4Km643UYcWNcrSfRkKL0WaiUcxcIbkXcVTgNpDqSnPXntWXT75cw== +lru_map@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" + integrity sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ== + luxon@2.5.2, luxon@^2.3.1: version "2.5.2" resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.5.2.tgz#17ed497f0277e72d58a4756d6a9abee4681457b6" @@ -16799,7 +16849,7 @@ tsconfig@^7.0.0: strip-bom "^3.0.0" strip-json-comments "^2.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==