diff --git a/packages/gatsby/gatsby-browser.js b/packages/gatsby/gatsby-browser.js index e0ab9d5199da..3e2598a0e0b5 100644 --- a/packages/gatsby/gatsby-browser.js +++ b/packages/gatsby/gatsby-browser.js @@ -1,7 +1,27 @@ +/* eslint-disable no-console */ const Sentry = require('@sentry/gatsby'); exports.onClientEntry = function(_, pluginParams) { - if (pluginParams === undefined) { + const isIntialized = isSentryInitialized(); + const areOptionsDefined = areSentryOptionsDefined(pluginParams); + + if (isIntialized) { + window.Sentry = Sentry; // For backwards compatibility + if (areOptionsDefined) { + console.warn( + 'Sentry Logger [Warn]: The SDK was initialized in the Sentry config file, but options were found in the Gatsby config. ' + + 'These have been ignored, merge them to the Sentry config if you want to use them.\n' + + 'Learn more about the Gatsby SDK on https://docs.sentry.io/platforms/javascript/guides/gatsby/', + ); + } + return; + } + + if (!areOptionsDefined) { + console.error( + 'Sentry Logger [Error]: No config for the Gatsby SDK was found.\n' + + 'Learn how to configure it on https://docs.sentry.io/platforms/javascript/guides/gatsby/', + ); return; } @@ -12,6 +32,21 @@ exports.onClientEntry = function(_, pluginParams) { dsn: __SENTRY_DSN__, ...pluginParams, }); - - window.Sentry = Sentry; + window.Sentry = Sentry; // For backwards compatibility }; + +function isSentryInitialized() { + // Although `window` should exist because we're in the browser (where this script + // is run), and `__SENTRY__.hub` is created when importing the Gatsby SDK, double + // check that in case something weird happens. + return !!(window && window.__SENTRY__ && window.__SENTRY__.hub && window.__SENTRY__.hub.getClient()); +} + +function areSentryOptionsDefined(params) { + if (params == undefined) return false; + // Even if there aren't any options, there's a `plugins` property defined as an empty array + if (Object.keys(params).length == 1 && Array.isArray(params.plugins) && params.plugins.length == 0) { + return false; + } + return true; +} diff --git a/packages/gatsby/gatsby-node.js b/packages/gatsby/gatsby-node.js index f8a6a74ad885..721196b1f943 100644 --- a/packages/gatsby/gatsby-node.js +++ b/packages/gatsby/gatsby-node.js @@ -1,3 +1,5 @@ +const fs = require('fs'); + const sentryRelease = JSON.stringify( // Always read first as Sentry takes this as precedence process.env.SENTRY_RELEASE || @@ -15,8 +17,9 @@ const sentryRelease = JSON.stringify( ); const sentryDsn = JSON.stringify(process.env.SENTRY_DSN || ''); +const SENTRY_USER_CONFIG = ['./sentry.config.js', './sentry.config.ts']; -exports.onCreateWebpackConfig = ({ plugins, actions }) => { +exports.onCreateWebpackConfig = ({ plugins, getConfig, actions }) => { actions.setWebpackConfig({ plugins: [ plugins.define({ @@ -25,4 +28,48 @@ exports.onCreateWebpackConfig = ({ plugins, actions }) => { }), ], }); + + // To configure the SDK, SENTRY_USER_CONFIG is prioritized over `gatsby-config.js`, + // since it isn't possible to set non-serializable parameters in the latter. + // Prioritization here means what `init` is run. + let configFile = null; + try { + configFile = SENTRY_USER_CONFIG.find(file => fs.existsSync(file)); + } catch (error) { + // Some node versions (like v11) throw an exception on `existsSync` instead of + // returning false. See https://github.com/tschaub/mock-fs/issues/256 + } + + if (!configFile) { + return; + } + // `setWebpackConfig` merges the Webpack config, ignoring some props like `entry`. See + // https://www.gatsbyjs.com/docs/reference/config-files/actions/#setWebpackConfig + // So it's not possible to inject the Sentry properties with that method. Instead, we + // can replace the whole config with the modifications we need. + const finalConfig = injectSentryConfig(getConfig(), configFile); + actions.replaceWebpackConfig(finalConfig); }; + +function injectSentryConfig(config, configFile) { + const injectedEntries = {}; + // TODO: investigate what entries need the Sentry config injected. + // We may want to skip some. + Object.keys(config.entry).forEach(prop => { + const value = config.entry[prop]; + let injectedValue = value; + if (typeof value === 'string') { + injectedValue = [configFile, value]; + } else if (Array.isArray(value)) { + injectedValue = [configFile, ...value]; + } else { + // eslint-disable-next-line no-console + console.error( + `Sentry Logger [Error]: Could not inject SDK initialization code into ${prop}, unexpected format: `, + typeof value, + ); + } + injectedEntries[prop] = injectedValue; + }); + return { ...config, entry: injectedEntries }; +} diff --git a/packages/gatsby/test/gatsby-browser.test.ts b/packages/gatsby/test/gatsby-browser.test.ts index e39926e77b37..a3c98524a2fd 100644 --- a/packages/gatsby/test/gatsby-browser.test.ts +++ b/packages/gatsby/test/gatsby-browser.test.ts @@ -16,6 +16,8 @@ jest.mock('@sentry/gatsby', () => { }, }; }); +global.console.warn = jest.fn(); +global.console.error = jest.fn(); let tracingAddExtensionMethods = jest.fn(); jest.mock('@sentry/tracing', () => { @@ -50,9 +52,81 @@ describe('onClientEntry', () => { } }); - it('sets window.Sentry', () => { - onClientEntry(undefined, {}); - expect((window as any).Sentry).not.toBeUndefined(); + describe('inits Sentry once', () => { + afterEach(() => { + delete (window as any).Sentry; + delete (window as any).__SENTRY__; + (global.console.warn as jest.Mock).mockClear(); + (global.console.error as jest.Mock).mockClear(); + }); + + function setMockedSentryInWindow() { + (window as any).__SENTRY__ = { + hub: { + getClient: () => ({ + // Empty object mocking the client + }), + }, + }; + } + + it('initialized in injected config, without pluginParams', () => { + setMockedSentryInWindow(); + onClientEntry(undefined, { plugins: [] }); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + expect(sentryInit).not.toHaveBeenCalled(); + expect((window as any).Sentry).toBeDefined(); + }); + + it('initialized in injected config, with pluginParams', () => { + setMockedSentryInWindow(); + onClientEntry(undefined, { plugins: [], dsn: 'dsn', release: 'release' }); + // eslint-disable-next-line no-console + expect((console.warn as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Sentry Logger [Warn]: The SDK was initialized in the Sentry config file, but options were found in the Gatsby config. These have been ignored, merge them to the Sentry config if you want to use them. + Learn more about the Gatsby SDK on https://docs.sentry.io/platforms/javascript/guides/gatsby/", + ] + `); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + expect(sentryInit).not.toHaveBeenCalled(); + expect((window as any).Sentry).toBeDefined(); + }); + + it('not initialized in injected config, without pluginParams', () => { + onClientEntry(undefined, { plugins: [] }); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect((console.error as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Sentry Logger [Error]: No config for the Gatsby SDK was found. + Learn how to configure it on https://docs.sentry.io/platforms/javascript/guides/gatsby/", + ] + `); + expect((window as any).Sentry).not.toBeDefined(); + }); + + it('not initialized in injected config, with pluginParams', () => { + onClientEntry(undefined, { plugins: [], dsn: 'dsn', release: 'release' }); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + expect(sentryInit).toHaveBeenCalledTimes(1); + expect(sentryInit.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "dsn": "dsn", + "plugins": Array [], + "release": "release", + } + `); + expect((window as any).Sentry).toBeDefined(); + }); }); it('sets a tracesSampleRate if defined as option', () => {