sentry-javascript icon indicating copy to clipboard operation
sentry-javascript copied to clipboard

Integration for FastifyJS

Open AbhiPrasad opened this issue 2 years ago • 5 comments

Problem Statement

https://www.fastify.io/

https://www.npmjs.com/package/fastify

Solution Brainstorm

Build an integration for Fastify, similar to what we do for Express

AbhiPrasad avatar Mar 25 '22 13:03 AbhiPrasad

Official support like express would be great!

iamchathu avatar Mar 26 '22 05:03 iamchathu

I wrote and use the following code for FastifyJS 3, it works but has problems due to global scoping. Might be useful to someone, better than nothing. I would be happy to have official support.

  function parseRequest(event: Event, req: FastifyRequest) {
    event.contexts = {
      ...event.contexts,
      runtime: {
        name: 'node',
        version: process.version,
      },
    };
  
    event.request = {
      ...event.request,
      url: `${req.protocol}://${req.hostname}${req.url}`,
      method: req.method,
      headers: req.headers as Record<string, string>,
      query_string: req.query as Record<string, string>,
    };
  
    return event;
  }

  fastify.addHook('onRequest', (req, reply, done) => {
    const currentHub = getCurrentHub();
    currentHub.pushScope();

    currentHub.configureScope((scope) => {
      scope.addEventProcessor((event) => parseRequest(event, req));
    });

    done();
  });

  fastify.addHook('onResponse', (req, reply, done) => {
    const currentHub = getCurrentHub();
    currentHub.popScope();

    done();
  });
  fastify.setErrorHandler(async (error: FastifyError, req, reply) => {
    if (!error.statusCode || error.statusCode >= 500) {
      withScope((scope) => {
        captureException(error);
      });

      return res.send('Something is wrong, please contact support');
    }

    return res.send(error);
  });

xr0master avatar Mar 28 '22 19:03 xr0master

I would love this. Fastify is a growing framework with over 500k downloads / month.

Xhale1 avatar Jun 24 '22 15:06 Xhale1

@AbhiPrasad Is there any progress in this direction? Can we at least discuss theoretically how to do this? I still don't understand how to achieve the scoping for single process nodejs code using the Sentry architecture. Since the hub will always be the same for all requests in a particular application. Some easy way to bind the scope to the request context and explain to the Sentry which scope to use in the current code block.

xr0master avatar Aug 02 '22 10:08 xr0master

You can bind the hub to the current request using domains or async hooks - storing it in async storage. This is what we do in nextjs for example - https://github.com/getsentry/sentry-javascript/pull/3788

It’s not ideal, but using async storage mechanisms is what every observability SDK for node uses. We will revaluate this at a later point.

AbhiPrasad avatar Aug 02 '22 13:08 AbhiPrasad

@AbhiPrasad Thanks for the link! As far as I can see, there is still a domain in use that is deprecated and "especially" not recommended. However, yes, without a serious rethinking of the Sentry architecture at the moment it is difficult to find another way. Not quite understood with async hooks. Do you mean memoize the scope and bind it to the request? But how then to remove the used scope from Sentry's hub?

xr0master avatar Aug 18 '22 21:08 xr0master

My first version of a plugin for catching errors in Fastify. I'm interested to see what anyone thinks. @AbhiPrasad This plugin does not add transactions, but I think there is no problem to add this by analogy with nextjs.

Known Issues

  • Outputs to the console through Fastify hooks will be duplicated in breadcrumbs from error to error.
  • ~~Works only if the route is registered.~~ (use await for plugin registration to resolve it)

Code

Register the plugin:

await app.register(sentry, {
  dsn: process.env.SENTRY_DNS,
});

@sentry/fastify file:

import { create } from 'domain';
import fastifyPlugin from 'fastify-plugin';
import { init, getCurrentHub, captureException, type NodeOptions, type Event } from '@sentry/node';
import type { FastifyRequest, FastifyPluginCallback, FastifyError } from 'fastify';

function parseRequest(event: Event, req: FastifyRequest) {
  event.contexts = {
    ...event.contexts,
    runtime: {
      name: 'node',
      version: process.version,
    },
  };

  event.request = {
    ...event.request,
    url: `${req.protocol}://${req.hostname}${req.url}`,
    method: req.method,
    headers: req.headers as Record<string, string>,
    query_string: req.query as Record<string, string>,
  };

  return event;
}

const sentry: FastifyPluginCallback<NodeOptions> = (fastify, options, next) => {
  init(options);

  fastify.addHook('onRoute', (options) => {
    const originalHandler = options.handler;

    options.handler = async (req, res) => {
      const domain = create();

      domain.add(req as never);
      domain.add(res as never);

      const boundHandler = domain.bind(async () => {
        getCurrentHub().configureScope((scope) => {
          scope.addEventProcessor((event) => parseRequest(event, req));
        });

        try {
          return await originalHandler.call(fastify, req, res);
        } catch (error) {
          // If there is no statusCode, it means an internal error that was not fired by Fastify.
          // Maybe it's good to catch 5xx errors as well. Can be removed or added as plugin options.
          if (!(error as FastifyError).statusCode || (error as FastifyError).statusCode! >= 500) {
            captureException(error);
          }
          throw error;
        }
      });

      await boundHandler();
    };
  });

  next();
};

export default fastifyPlugin(sentry, {
  fastify: '4.x',
  name: '@sentry/fastify',
});

xr0master avatar Aug 20 '22 16:08 xr0master

I am using @fastify/middle .

  • Http request handler is working.

// eslint-disable-next-line @typescript-eslint/no-var-requires
  const Sentry = require('@sentry/node');
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { ProfilingIntegration } = require('@sentry/profiling-node');
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  // const Tracing = require('@sentry/tracing');
  Sentry.init({
    dsn: dataSourceName,
    integrations: [
      // enable HTTP calls tracing
      new Sentry.Integrations.Http({ tracing: true }),
      // // enable Express.js middleware tracing
      // new Tracing.Integrations.Express({ app: app.getHttpAdapter().getInstance() }),
      new ProfilingIntegration(),
    ],

    // Set tracesSampleRate to 1.0 to capture 100%
    // of transactions for performance monitoring.
    // We recommend adjusting this value in production
    tracesSampleRate: 1.0,
    profilesSampleRate: 1.0,
    // or pull from params
    // tracesSampleRate: parseFloat(params.SENTRY_TRACES_SAMPLE_RATE),
  });

  // RequestHandler creates a separate execution context using domains, so that every
  // transaction/span/breadcrumb is attached to its own Hub instance
  app.use(Sentry.Handlers.requestHandler());
  // TracingHandler creates a trace for every incoming request
  // app.use(Sentry.Handlers.tracingHandler());
  • But the tracing module is not working with integrations Tracing.Integrations.Express
// eslint-disable-next-line @typescript-eslint/no-var-requires
  const Sentry = require('@sentry/node');
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { ProfilingIntegration } = require('@sentry/profiling-node');
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const Tracing = require('@sentry/tracing');
  Sentry.init({
    dsn: dataSourceName,
    integrations: [
      // enable HTTP calls tracing
      new Sentry.Integrations.Http({ tracing: true }),
      // enable Express.js middleware tracing
      new Tracing.Integrations.Express({ app: app.getHttpAdapter().getInstance() }),
      new ProfilingIntegration(),
    ],

    // Set tracesSampleRate to 1.0 to capture 100%
    // of transactions for performance monitoring.
    // We recommend adjusting this value in production
    tracesSampleRate: 1.0,
    profilesSampleRate: 1.0,
    // or pull from params
    // tracesSampleRate: parseFloat(params.SENTRY_TRACES_SAMPLE_RATE),
  });

  // RequestHandler creates a separate execution context using domains, so that every
  // transaction/span/breadcrumb is attached to its own Hub instance
  app.use(Sentry.Handlers.requestHandler());
  // TracingHandler creates a trace for every incoming request
  app.use(Sentry.Handlers.tracingHandler());

biaomingzhong avatar Dec 21 '22 08:12 biaomingzhong

Hi I'm assisting an organization that could benefit from this.

thinkocapo avatar Aug 22 '23 09:08 thinkocapo

@thinkocapo The new version of the code for the Sentry Async API

import fastifyPlugin from 'fastify-plugin';
import {
  init,
  getCurrentHub,
  captureException,
  runWithAsyncContext,
  type NodeOptions,
  type Event,
} from '@sentry/node';

import type { FastifyRequest, FastifyPluginCallback, FastifyError } from 'fastify';

function parseRequest(event: Event, req: FastifyRequest) {
  event.contexts = {
    ...event.contexts,
    runtime: {
      name: 'node',
      version: process.version,
    },
  };

  event.request = {
    ...event.request,
    url: `${req.protocol}://${req.hostname}${req.url}`,
    method: req.method,
    headers: req.headers as Record<string, string>,
    query_string: req.query as Record<string, string>,
  };

  return event;
}

const sentry: FastifyPluginCallback<NodeOptions> = (fastify, options, next) => {
  init(options);

  fastify.addHook('onRoute', (options) => {
    const originalHandler = options.handler;

    options.handler = async (req, res) => {
      return runWithAsyncContext(async () => {
        getCurrentHub().configureScope((scope) => {
          scope.addEventProcessor((event) => parseRequest(event, req));
        });

        try {
          return await originalHandler.call(fastify, req, res);
        } catch (error) {
          // If there is no statusCode, it means an internal error that was not fired by Fastify.
          // Maybe it's good to catch 5xx errors as well. Can be removed or added as plugin options.
          if (!(error as FastifyError).statusCode || (error as FastifyError).statusCode! >= 500) {
            captureException(error);
            console.log('onError', error);
          }
          throw error;
        }
      });
    };
  });

  next();
};

export default fastifyPlugin(sentry, {
  fastify: '4.x',
  name: '@sentry/fastify',
});

P.S. I have been using it for production for a long time without any issues.

xr0master avatar Aug 28 '23 08:08 xr0master

Very open to help work on this, is there anything that needs assistance with? I work for a major company which uses Fastify extensively and recently started evaluating Sentry. Not having native official support is a major roadblock for general adoption currently.

csvan avatar Dec 22 '23 18:12 csvan

@csvan, you may take the https://github.com/getsentry/sentry-javascript/issues/4784#issuecomment-1695298330 code, add the "tracing" functionality, and pack it to the npm package.

xr0master avatar Dec 23 '23 04:12 xr0master

In the upcoming v8 release of the SDK, we will switch to OpenTelemetry for performance instrumentation. Once we have this done, we will make sure to provide better support for Fastify overall! We'll keep you posted.

mydea avatar Jan 04 '24 08:01 mydea

A slightly cleaned up version:

import {
  captureException,
  type Event,
  getCurrentScope,
  type NodeOptions,
  runWithAsyncContext,
} from '@sentry/node';
import {
  type FastifyError,
  type FastifyPluginCallback,
  type FastifyRequest,
} from 'fastify';
import fastifyPlugin from 'fastify-plugin';

const parseRequest = (event: Event, request: FastifyRequest) => {
  event.contexts = {
    ...event.contexts,
    runtime: {
      name: 'node',
      version: process.version,
    },
  };

  event.request = {
    ...event.request,
    headers: request.headers as Record<string, string>,
    method: request.method,
    query_string: request.query as Record<string, string>,
    url: `${request.protocol}://${request.hostname}${request.url}`,
  };

  return event;
};

const isFastifyError = (error: unknown): error is FastifyError => {
  return Object.prototype.toString.call(error) === '[object FastifyError]';
};

const sentry: FastifyPluginCallback<NodeOptions> = (
  fastify,
  _options,
  next,
) => {
  fastify.addHook('onRoute', (options) => {
    const originalHandler = options.handler;

    options.handler = async (request, reply) => {
      return runWithAsyncContext(async () => {
        const scope = getCurrentScope();

        scope.addEventProcessor((event) => parseRequest(event, request));

        try {
          return await originalHandler.call(fastify, request, reply);
        } catch (error) {
          if (!isFastifyError(error)) {
            throw error;
          }

          // If there is no statusCode, it means an internal error that was not fired by Fastify.
          // Maybe it's good to catch 5xx errors as well. Can be removed or added as plugin options.
          if (error.statusCode && error.statusCode >= 500) {
            captureException(error);
          }

          throw error;
        }
      });
    };
  });

  next();
};

/**
 * @see https://github.com/getsentry/sentry-javascript/issues/4784#issuecomment-1695298330
 */
export const isolateScope = fastifyPlugin(sentry, {
  fastify: '4.x',
  name: '@sentry/fastify',
});

I would separate things like init and instrumentation.

gajus avatar Jan 29 '24 19:01 gajus

Ended up with just:

/**
 * @see https://github.com/getsentry/sentry-javascript/pull/9138/files
 */
import {
  captureException,
  getCurrentScope,
  runWithAsyncContext,
} from '@sentry/node';
import { type FastifyPluginCallback } from 'fastify';

const SKIP_OVERRIDE = Symbol.for('skip-override');
const FASTIFY_DISPLAY_NAME = Symbol.for('fastify.display-name');

export const fastifyRequestPlugin = (): FastifyPluginCallback =>
  Object.assign(
    (fastify, _options: unknown, pluginDone: () => void) => {
      fastify.addHook('onRequest', (request, _reply, done) => {
        runWithAsyncContext(() => {
          const scope = getCurrentScope();

          scope.setSDKProcessingMetadata({
            request,
          });

          done();
        });
      });

      pluginDone();
    },
    {
      [FASTIFY_DISPLAY_NAME]: 'SentryFastifyRequestPlugin',
      [SKIP_OVERRIDE]: true,
    },
  );

export const fastifyErrorPlugin = (): FastifyPluginCallback =>
  Object.assign(
    (fastify, _options: unknown, pluginDone: () => void) => {
      fastify.addHook('onError', (_request, _reply, error, done) => {
        captureException(error);

        done();
      });

      pluginDone();
    },
    {
      [FASTIFY_DISPLAY_NAME]: 'SentryFastifyErrorPlugin',
      [SKIP_OVERRIDE]: true,
    },
  );

gajus avatar Jan 30 '24 00:01 gajus

Coming soon in v8 of the sdk (alpha out now: https://www.npmjs.com/package/@sentry/node/v/8.0.0-alpha.9)

Example app: https://github.com/getsentry/sentry-javascript/tree/develop/dev-packages/e2e-tests/test-applications/node-fastify-app

const Sentry = require('@sentry/node');

Sentry.init({
  dsn: process.env.E2E_TEST_DSN,
  // distributed tracing + performance monitoring automatically enabled for fastify
  tracesSampleRate: 1,
});

// Make sure Sentry is initialized before you require fastify
const { fastify } = require('fastify');

const app = fastify();

// add error handler to whatever fastify router instances you want (we recommend all of them!)
Sentry.setupFastifyErrorHandler(app);

AbhiPrasad avatar Apr 10 '24 20:04 AbhiPrasad

Screenshot 2024-04-10 at 1 56 58 PM

@AbhiPrasad Just heads up that we are getting a type error with Sentry alpha-9 and Fastify 4.24.3.

gajus avatar Apr 10 '24 20:04 gajus

shoot we're on it! Thanks @gajus

AbhiPrasad avatar Apr 10 '24 21:04 AbhiPrasad