Example update: with-sentry-simple (#8684)

* Update to capture server exceptions and more

- Adds test cases for several server and client-side exceptions
- Allows capturing more server-side exceptions by overriding _error.js and using Sentry.captureException() within
- Use @sentry/node on the server
- Rely on Next.js's React Error Boundary instead of creating our own in _app.js

* Update test notes

Found some differences while testing in production

* Remove accidental mount throw on test 8

* Add note about server-side source maps

* Linting fixes
This commit is contained in:
Weston Thayer 2019-09-17 02:43:51 -07:00 committed by Tim Neutkens
parent aa98323ed0
commit dc28e5b706
19 changed files with 445 additions and 94 deletions

View file

@ -49,11 +49,19 @@ now
This is a simple example showing how to use [Sentry](https://sentry.io) to catch & report errors on both client + server side.
- `_document.js` is _server-side only_ and is used to change the initial server-side rendered document markup. We listen at the node process level to capture exceptions.
- `_app.js` is client-side only and is used to initialize pages. We use the `componentDidCatch` lifecycle method to catch uncaught exceptions.
- `_app.js` renders on both the server and client. It initializes Sentry to catch any unhandled exceptions
- `_error.js` is rendered by Next.js while handling certain types of exceptions for you. It is overriden so those exceptions can be passed along to Sentry
- `next.config.js` enables source maps in production for Sentry and swaps out `@sentry/node` for `@sentry/browser` when building the client bundle
**Note**: Source maps will not be sent to Sentry when running locally. It's also possible you will see duplicate errors sent when testing
locally due to hot reloading. For a more accurate simulation, please deploy to Now.
**Note**: Source maps will not be sent to Sentry when running locally (because Sentry cannot access your `localhost`). To accurately test client-side source maps, please deploy to Now.
**Note**: Server-side source maps will not work unless you [manually upload them to Sentry](https://docs.sentry.io/platforms/node/sourcemaps/#making-source-maps-available-to-sentry).
**Note**: Error handling [works differently in production](https://nextjs.org/docs#custom-error-handling). Some exceptions will not be sent to Sentry in development mode (i.e. `npm run dev`).
**Note**: The build output will contain warning about unhandled Promise rejections. This caused by the test pages, and is expected.
**Note**: The version of `@zeit/next-source-maps` (`0.0.4-canary.1`) is important and must be specified since it is not yet the default. Otherwise [source maps will not be generated for the server](https://github.com/zeit/next-plugins/issues/377).
### Configuration
@ -67,4 +75,29 @@ Sentry.init({
})
```
_Note: Committing environment variables is not secure and is done here only for demonstration purposes. See the [`with-dotenv`](../with-dotenv) or [`with-now-env`](../with-now-env) for examples of how to set environment variables safely._
### Disabling Sentry during development
An easy way to disable Sentry while developing is to set its `enabled` flag based off of the `NODE_ENV` environment variable, which is [properly configured by the `next` subcommands](https://nextjs.org/docs#production-deployment).
```js
Sentry.init({
dsn: 'PUT_YOUR_SENTRY_DSN_HERE',
enabled: process.env.NODE_ENV === 'production'
})
```
### Hosting source maps vs. uploading them to Sentry
This example shows how to generate your own source maps, which are hosted alongside your JavaScript bundles in production. But that has the potential for innaccurate results in Sentry.
Sentry will attempt to [fetch the source map](https://docs.sentry.io/platforms/javascript/sourcemaps/#hosting--uploading) when it is processing an exception, as long as the "Enable JavaScript source fetching" setting is turned on for your Sentry project.
However, there are some disadvantages with this approach. Sentry has written a blog post about them here: https://blog.sentry.io/2018/07/17/source-code-fetching
If you decide that uploading source maps to Sentry would be better, one approach is to define a custom `now-build` script in your `package.json`. Zeit Now's `@now/next` builder will [call this script](https://github.com/zeit/now/blob/canary/packages/now-next/src/index.ts#L270) for you. You can define what to do after a build there:
```
"now-build": "next build && node ./post-build.js"
```
In `./post-build.js` you can `require('@sentry/cli')` and go through the process of creating a Sentry release and [uploading source maps](https://docs.sentry.io/cli/releases/#sentry-cli-sourcemaps), and optionally deleting the `.js.map` files so they are not made public.

View file

@ -1,7 +1,25 @@
const withSourceMaps = require('@zeit/next-source-maps')()
module.exports = withSourceMaps({
webpack (config, _options) {
webpack: (config, options) => {
// In `pages/_app.js`, Sentry is imported from @sentry/node. While
// @sentry/browser will run in a Node.js environment, @sentry/node will use
// Node.js-only APIs to catch even more unhandled exceptions.
//
// This works well when Next.js is SSRing your page on a server with
// Node.js, but it is not what we want when your client-side bundle is being
// executed by a browser.
//
// Luckily, Next.js will call this webpack function twice, once for the
// server and once for the client. Read more:
// https://nextjs.org/docs#customizing-webpack-config
//
// So ask Webpack to replace @sentry/node imports with @sentry/browser when
// building the browser's bundle
if (!options.isServer) {
config.resolve.alias['@sentry/node'] = '@sentry/browser'
}
return config
}
})

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"@sentry/browser": "^5.1.0",
"@sentry/node": "^5.6.2",
"next": "latest",
"react": "^16.8.6",
"react-dom": "^16.8.6"

View file

@ -1,28 +1,21 @@
import React from 'react'
import App from 'next/app'
import * as Sentry from '@sentry/browser'
import * as Sentry from '@sentry/node'
Sentry.init({
dsn: 'ENTER_YOUR_SENTRY_DSN_HERE'
// Replace with your project's Sentry DSN
dsn: 'https://00000000000000000000000000000000@sentry.io/1111111'
})
class MyApp extends App {
componentDidCatch (error, errorInfo) {
Sentry.withScope(scope => {
Object.keys(errorInfo).forEach(key => {
scope.setExtra(key, errorInfo[key])
})
Sentry.captureException(error)
})
super.componentDidCatch(error, errorInfo)
}
render () {
const { Component, pageProps } = this.props
return <Component {...pageProps} />
// Workaround for https://github.com/zeit/next.js/issues/8592
const { err } = this.props
const modifiedPageProps = { ...pageProps, err }
return <Component {...modifiedPageProps} />
}
}

View file

@ -1,31 +0,0 @@
import Document, { Html, Head, Main, NextScript } from 'next/document'
import * as Sentry from '@sentry/browser'
process.on('unhandledRejection', err => {
Sentry.captureException(err)
})
process.on('uncaughtException', err => {
Sentry.captureException(err)
})
class MyDocument extends Document {
static async getInitialProps (ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render () {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument

View file

@ -0,0 +1,64 @@
import React from 'react'
import Error from 'next/error'
import * as Sentry from '@sentry/node'
const MyError = ({ statusCode, hasGetInitialPropsRun, err }) => {
if (!hasGetInitialPropsRun && err) {
// getInitialProps is not called in case of
// https://github.com/zeit/next.js/issues/8592. As a workaround, we pass
// err via _app.js so it can be captured
Sentry.captureException(err)
}
return <Error statusCode={statusCode} />
}
MyError.getInitialProps = async ({ res, err, asPath }) => {
const errorInitialProps = await Error.getInitialProps({ res, err })
// Workaround for https://github.com/zeit/next.js/issues/8592, mark when
// getInitialProps has run
errorInitialProps.hasGetInitialPropsRun = true
if (res) {
// Running on the server, the response object is available.
//
// Next.js will pass an err on the server if a page's `getInitialProps`
// threw or returned a Promise that rejected
if (res.statusCode === 404) {
// Opinionated: do not record an exception in Sentry for 404
return { statusCode: 404 }
}
if (err) {
Sentry.captureException(err)
return errorInitialProps
}
} else {
// Running on the client (browser).
//
// Next.js will provide an err if:
//
// - a page's `getInitialProps` threw or returned a Promise that rejected
// - an exception was thrown somewhere in the React lifecycle (render,
// componentDidMount, etc) that was caught by Next.js's React Error
// Boundary. Read more about what types of exceptions are caught by Error
// Boundaries: https://reactjs.org/docs/error-boundaries.html
if (err) {
Sentry.captureException(err)
return errorInitialProps
}
}
// If this point is reached, getInitialProps was called without any
// information about what the error might be. This is unexpected and may
// indicate a bug introduced in Next.js, so record it in Sentry
Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${asPath}`))
return errorInitialProps
}
export default MyError

View file

@ -0,0 +1,9 @@
import React from 'react'
const Test1 = () => <h1>Client Test 1</h1>
Test1.getInitialProps = () => {
throw new Error('Client Test 1')
}
export default Test1

View file

@ -0,0 +1,7 @@
import React from 'react'
const Test2 = () => <h1>Client Test 2</h1>
Test2.getInitialProps = () => Promise.reject(new Error('Client Test 2'))
export default Test2

View file

@ -0,0 +1,13 @@
import React from 'react'
const Test3 = () => <h1>Client Test 3</h1>
Test3.getInitialProps = () => {
const doAsyncWork = () => Promise.reject(new Error('Client Test 3'))
doAsyncWork()
return {}
}
export default Test3

View file

@ -0,0 +1,8 @@
import React from 'react'
const doAsyncWork = () => Promise.reject(new Error('Client Test 4'))
doAsyncWork()
const Test4 = () => <h1>Client Test 4</h1>
export default Test4

View file

@ -0,0 +1,19 @@
import React from 'react'
// This code will run just fine on the server in Node.js, but process will be
// undefined in a browser. Note that `isProd = process.env.NODE_ENV` would have
// worked because Webpack's DefinePlugin will replace it with a string at build
// time: https://nextjs.org/docs#build-time-configuration
const env = process.env
const isProd = env.NODE_ENV === 'production'
const Test5 = () => (
<React.Fragment>
<h1>Client Test 5</h1>
<p>
isProd: {isProd}
</p>
</React.Fragment>
)
export default Test5

View file

@ -0,0 +1,11 @@
import React from 'react'
const Test6 = () => {
React.useEffect(() => {
throw new Error('Client Test 6')
}, [])
return <h1>Client Test 6</h1>
}
export default Test6

View file

@ -0,0 +1,13 @@
import React from 'react'
const Test7 = () => {
React.useEffect(async () => {
const doAsyncWork = () => Promise.reject(new Error('Client Test 7'))
const result = await doAsyncWork()
console.log(result)
}, [])
return <h1>Client Test 7</h1>
}
export default Test7

View file

@ -0,0 +1,16 @@
import React from 'react'
const Test8 = () => (
<React.Fragment>
<h1>Client Test 8</h1>
<button
onClick={() => {
throw new Error('Client Test 8')
}}
>
Click me to throw an Error
</button>
</React.Fragment>
)
export default Test8

View file

@ -1,49 +1,183 @@
import React from 'react'
import Link from 'next/link'
class Index extends React.Component {
static getInitialProps ({ query }) {
if (query.raiseError) {
throw new Error('Error in getInitialProps')
}
}
state = {
raiseErrorInRender: false,
raiseErrorInUpdate: false
}
componentDidUpdate () {
if (this.state.raiseErrorInUpdate) {
throw new Error('Error in componentDidUpdate')
}
}
raiseErrorInUpdate = () => this.setState({ raiseErrorInUpdate: '1' })
raiseErrorInRender = () => this.setState({ raiseErrorInRender: '1' })
render () {
if (this.state.raiseErrorInRender) {
throw new Error('Error in render')
}
return (
<div>
<h2>Sentry Example 🚨</h2>
const Index = () => (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
<h2>Sentry Simple Example 🚨</h2>
<p>
This example demonstrates how to record unhandled exceptions in your code
with Sentry. There are several test pages below that result in various
kinds of unhandled exceptions.
</p>
<p>
<strong>Important:</strong> exceptions in development mode take a
different path than in production. These tests should be run on a
production build (i.e. 'next build').
{' '}
<a href='https://nextjs.org/docs#custom-error-handling'>Read more</a>
</p>
<ul>
<li>Server exceptions</li>
<ul>
<li>
<a href='#' onClick={this.raiseErrorInRender}>
Raise the error in render
getInitialProps throws an Error. This should cause _error.js to
render and record Error('Client Test 1') in Sentry.
{' '}
<a href='/server/test1' target='_blank'>
Open in a new tab
</a>
</li>
<li>
<a href='#' onClick={this.raiseErrorInUpdate}>
Raise the error in componentDidUpdate
getInitialProps returns a Promise that rejects. This should cause
_error.js to render and record Error('Server Test 2') in Sentry.
{' '}
<a href='/server/test2' target='_blank'>
Open in a new tab
</a>
</li>
<li>
getInitialProps calls a Promise that rejects, but does not handle the
rejection or await its result (returning synchronously). Sentry should
record Error('Server Test 3').
{' '}
<a href='/server/test3' target='_blank'>
Open in a new tab
</a>
</li>
<li>
There is a top-of-module Promise that rejects, but its result is not
awaited. Sentry should record Error('Server Test 4'). Note this will
also be recorded on the client side, once the page is hydrated.
{' '}
<a href='/server/test4' target='_blank'>
Open in a new tab
</a>
</li>
</ul>
<li>Client exceptions</li>
<ul>
<li>
getInitialProps throws an Error. This should cause _error.js to render
and record Error('Client Test 1') in Sentry. Note Sentry will double
count this exception. Once from an unhandledrejection and again in
_error.js. Could be a bug in Next.js or Sentry, requires more
debugging.
{' '}
<Link href='/client/test1'>
<a>
Perform client side navigation
</a>
</Link>
</li>
<li>
getInitialProps returns a Promise that rejects. This should cause
_error.js to render and record Error('Client Test 2') in Sentry. As
above, Sentry will double count this exception.
{' '}
<Link href='/client/test2'>
<a>
Perform client side navigation
</a>
</Link>
</li>
<li>
getInitialProps calls a Promise that rejects, but does not handle the
rejection or await its result (returning synchronously). Sentry should
record Error('Client Test 3').
{' '}
<Link href='/client/test3'>
<a>
Perform client side navigation
</a>
</Link>
</li>
<li>
There is a top-of-module Promise that rejects, but its result is not
awaited. Sentry should record Error('Client Test 4').
{' '}
<Link href='/client/test4'>
<a>
Perform client side navigation
</a>
</Link>
{' '}
or
{' '}
<a href='/client/test4' target='_blank'>
Open in a new tab
</a>
</li>
<li>
There is a top-of-module exception. _error.js should render and record
ReferenceError('process is not defined') in Sentry.
{' '}
<Link href='/client/test5'>
<a>
Perform client side navigation
</a>
</Link>
{' '}
or
{' '}
<a href='/client/test5' target='_blank'>
Open in a new tab
</a>
</li>
<li>
There is an exception during React lifecycle that is caught by
Next.js's React Error Boundary. In this case, when the component
mounts. This should cause _error.js to render and record
Error('Client Test 6') in Sentry.
{' '}
<Link href='/client/test6'>
<a>
Perform client side navigation
</a>
</Link>
{' '}
or
{' '}
<a href='/client/test6' target='_blank'>
Open in a new tab
</a>
</li>
<li>
There is an unhandled Promise rejection during React lifecycle. In
this case, when the component mounts. Sentry should record
Error('Client Test 7').
{' '}
<Link href='/client/test7'>
<a>
Perform client side navigation
</a>
</Link>
{' '}
or
{' '}
<a href='/client/test7' target='_blank'>
Open in a new tab
</a>
</li>
<li>
An Error is thrown from an event handler. Sentry should record
Error('Client Test 8').
{' '}
<Link href='/client/test8'>
<a>
Perform client side navigation
</a>
</Link>
{' '}
or
{' '}
<a href='/client/test8' target='_blank'>
Open in a new tab
</a>
</li>
</ul>
</ul>
</div>
)
}
}
export default Index

View file

@ -0,0 +1,9 @@
import React from 'react'
const Test1 = () => <h1>Server Test 1</h1>
Test1.getInitialProps = () => {
throw new Error('Server Test 1')
}
export default Test1

View file

@ -0,0 +1,7 @@
import React from 'react'
const Test2 = () => <h1>Server Test 2</h1>
Test2.getInitialProps = () => Promise.reject(new Error('Server Test 2'))
export default Test2

View file

@ -0,0 +1,13 @@
import React from 'react'
const Test3 = () => <h1>Server Test 3</h1>
Test3.getInitialProps = () => {
const doAsyncWork = () => Promise.reject(new Error('Server Test 3'))
doAsyncWork()
return {}
}
export default Test3

View file

@ -0,0 +1,14 @@
import React from 'react'
const doAsyncWork = () => Promise.reject(new Error('Server Test 4'))
doAsyncWork()
const Test4 = () => <h1>Server Test 4</h1>
// Define getInitialProps so that the page will be rendered on the server
// instead of statically
Test4.getInitialProps = () => {
return {}
}
export default Test4