Vendure.io error handling with sentry

2024-05-01 update. Vendure added sentry plugin, which might make most of this post useless. I did not have time to explore the plugin yet. https://docs.vendure.io/reference/core-plugins/sentry-plugin/

Vendure.io is a great framework, but the official documentation does not specify anything related to error logging, or even where does the error boundary end.

Vendure does by default handle all errors thrown through the graphql and rest endpoints.

But it is quite easy to stop the whole server by simply not handling exception for example in event subscribers.

async onApplicationBootstrap() 
{

    this.eventBus
        .ofType(eventType)
        .pipe(filterRule)
        .subscribe(async (event) => {
            throw new Error('Not allowed')
        });
}

You will stop the entire server process by throwing exception, and it is quite easy to do such mistake.

Setup

Install sentry

yarn add @sentry/node
yarn add @sentry/tracing

Initialize sentry

in your vendure-config file

Sentry.init({  
    dsn: SENTRY_DSN,  
    tracesSampleRate: 1.0,  
    debug: IS_DEV,  
});

export const config: VendureConfig = {
...
}

Handling for server

Errors that are thrown during the request-response cycle are caught and returned in the graphql. So here we only need to add logging.

Create Exception filter

import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';  
import * as Sentry from "@sentry/node";  
import "@sentry/tracing";  
  

@Catch()  
export class SentryExceptionFilter implements ExceptionFilter {  
    catch(exception: Error, host: ArgumentsHost) {
        Sentry.captureException(exception)  
    }  
}

In some plugin

@VendurePlugin({  
    ...
    providers: [  
        ...
        {  
            provide: APP_FILTER,  
            useClass: SentryExceptionFilter,  
        },  
    ],  
})

This captures the error when returing through GraphQL, but it still does not solve the issue for consumers and cronjobs.

Handling in event subscribers

To make sure all subscribers are error-free, I created a simple reusable event subscriber.

export abstract class BaseSubscriber<T extends VendureEvent & {ctx: RequestContext}> {
    constructor(
        protected transactionalConnection: TransactionalConnection
    ) {
    }

    public async subscribe(event: T): Promise<void> {
        try {
            await this.transactionalConnection.withTransaction(event.ctx, async (ctx) => {
                await this.doSubscribe(ctx, event);
            })
        } catch (e) {
            Sentry.captureException(e);
        }
    }

    protected abstract doSubscribe(ctx: RequestContext, event: T): Promise<void>;
}

The actual event subscriber will look like this

@Injectable()
export class FulfillmentOnFulfillmentCreatedSubscriber extends BaseSubscriber<FulfillmentEvent> {
    public static eventType = FulfillmentEvent;
    public static filterRule = filter((event: FulfillmentEvent) => event.entity.handlerCode === CONTEST_FULFILLMENT_HANDLER_CODE);

    constructor(
        transactionalConnection: TransactionalConnection
    ) {
        super(transactionalConnection);
    }

    public async doSubscribe(ctx: RequestContext, event: FulfillmentEvent): Promise<void>
    {
        // do your action
    }
}

Then add the event subscriber like so in your plugin

async onApplicationBootstrap() {
    this.eventBus
        .ofType(CouponFulfillmentOnPaymentSettledSubscriber.eventType)
        .pipe(CouponFulfillmentOnPaymentSettledSubscriber.filterRule)
        .subscribe(this.couponFulfillmentOnPaymentSettledSubscriber.subscribe);
}

Handling for crons

Manually wrapping each cron in the exception handler

const createSafeTransactionalCron = (app: INestApplicationContext, time: string, callback: (ctx: RequestContext) => Promise<void>) => {  
    return new CronJob(time, async () => {  
        const sentryLogger = app.get(SentryLogger);  
        const connection = app.get(TransactionalConnection);  
  
        try {  
            await connection.withTransaction(async (ctx) => {  
                await callback(ctx)  
            })  
        } catch (e) {  
            sentryLogger.logException(e);  
        }  
    })  
}

In worker

bootstrapWorker(config)  
.then(worker => {  
    const job = createSafeTransactionalCron(worker.app, `*/15 * * * * *`, async (ctx) => {  
        throw new Error('Test error');
    });  
    job.start();  
})

This should ensure that the app does not crash, and all exceptions will be visible in Sentry