Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve with-sentry example #5727

Merged
merged 6 commits into from
Dec 10, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/with-sentry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ This example show you how to add Sentry to catch errors in next.js

You will need a Sentry DSN for your project. You can get it from the Settings of your Project, in **Client Keys (DSN)**, and copy the string labeled **DSN (Public)**.
Note that if you are using a custom server, there is logging available for common platforms: https://docs.sentry.io/platforms/javascript/express/?platform=node

You can set SENTRY_DSN in next.config.js

If you want sentry to show non-minified sources you need to set SENTRY_TOKEN environment variable when starting server. You can find it in project settings under "Security Token" section.
23 changes: 23 additions & 0 deletions examples/with-sentry/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const webpack = require('webpack')
const nextSourceMaps = require('@zeit/next-source-maps')()

const SENTRY_DSN = ''

module.exports = nextSourceMaps({
webpack: (config, { dev, isServer, buildId }) => {
if (!dev) {
config.plugins.push(
new webpack.DefinePlugin({
'process.env.SENTRY_DSN': JSON.stringify(SENTRY_DSN),
'process.env.SENTRY_RELEASE': JSON.stringify(buildId)
})
)
}

if (!isServer) {
config.resolve.alias['@sentry/node'] = '@sentry/browser'
}

return config
}
})
16 changes: 11 additions & 5 deletions examples/with-sentry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@
"name": "with-sentry",
"version": "1.0.0",
"scripts": {
"dev": "next",
"dev": "node server.js",
"build": "next build",
"start": "next start"
"start": "NODE_ENV=production node server.js"
},
"dependencies": {
"next": "latest",
"@sentry/browser": "^4.0.4",
"@sentry/browser": "^4.3.4",
"@sentry/node": "^4.3.4",
"@zeit/next-source-maps": "0.0.4-canary.0",
"cookie-parser": "^1.4.3",
"express": "^4.16.4",
"js-cookie": "^2.2.0",
"next": "7.0.2",
"react": "^16.5.2",
"react-dom": "^16.5.2"
"react-dom": "^16.5.2",
"uuid": "^3.3.2"
},
"license": "ISC"
}
37 changes: 23 additions & 14 deletions examples/with-sentry/pages/_app.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import App from 'next/app'
import * as Sentry from '@sentry/browser'
import { captureException } from '../utils/sentry'

const SENTRY_PUBLIC_DSN = ''
class MyApp extends App {
// This reports errors before rendering, when fetching initial props
static async getInitialProps (appContext) {
const { Component, ctx } = appContext

export default class MyApp extends App {
constructor (...args) {
super(...args)
Sentry.init({dsn: SENTRY_PUBLIC_DSN})
let pageProps = {}

try {
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx)
}
} catch (e) {
captureException(e, ctx)
Copy link
Member

@timneutkens timneutkens Nov 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to _error.js instead, we use something like this for zeit.co:

  static getInitialProps({ res, err }) {
    console.log({err})
    const statusCode = res ? res.statusCode : err ? err.statusCode : null
    Sentry.captureException(err)
    return { statusCode }
  }

Also passing ctx is dangerous when server errors happen, as it holds req and res, which can hold auth token cookies etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't really work and results in Synthetic errors. ctx is passed to 1st party captureException from which relevant data is extracted. I don't see how it's security issue

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried lots of different ways to integrate sentry in the _error.js but all of them resulted in Synthetic errors

throw e // you can also skip re-throwing and set property on pageProps
}

return {
pageProps
}
}

// This reports errors thrown while rendering components
componentDidCatch (error, errorInfo) {
Sentry.configureScope(scope => {
Object.keys(errorInfo).forEach(key => {
scope.setExtra(key, errorInfo[key])
})
})
Sentry.captureException(error)

// This is needed to render errors correctly in development / production
captureException(error, { extra: errorInfo })
super.componentDidCatch(error, errorInfo)
}
}

export default MyApp
33 changes: 29 additions & 4 deletions examples/with-sentry/pages/index.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import React from 'react'
import Link from 'next/link'

class Index extends React.Component {
static getInitialProps ({ query, req }) {
if (query.raiseError) {
throw new Error('Error in getInitialProps')
}
}

state = {
raiseError: false
}

componentDidUpdate () {
if (this.state.raiseError) {
throw new Error('Houston, we have a problem')
if (this.state.raiseErrorInUpdate) {
throw new Error('Error in componentDidUpdate')
}
}

raiseError = () => this.setState({ raiseError: true })
raiseErrorInUpdate = () => this.setState({ raiseErrorInUpdate: '1' })
raiseErrorInRender = () => this.setState({ raiseErrorInRender: '1' })

render () {
if (this.state.raiseErrorInRender) {
throw new Error('Error in render')
}

return (
<div>
<h2>Index page</h2>
<button onClick={this.raiseError}>Click to raise the error</button>
<ul>
<li><a href='#' onClick={this.raiseErrorInRender}>Raise the error in render</a></li>
<li><a href='#' onClick={this.raiseErrorInUpdate}>Raise the error in componentDidUpdate</a></li>
<li>
<Link href={{ pathname: '/', query: { raiseError: '1' } }}>
<a>Raise error in getInitialProps of client-loaded page</a>
</Link>
</li>
<li>
<a href='/?raiseError=1'>
Raise error in getInitialProps of server-loaded page
</a>
</li>
</ul>
</div>
)
}
Expand Down
66 changes: 66 additions & 0 deletions examples/with-sentry/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const next = require('next')
const express = require('express')
const cookieParser = require('cookie-parser')
const Sentry = require('@sentry/node')
const uuidv4 = require('uuid/v4')
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

require('./utils/sentry')

app.prepare()
.then(() => {
const server = express()

// This attaches request information to sentry errors
server.use(Sentry.Handlers.requestHandler())

server.use(cookieParser())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's needed for tracking users so we get same user id on client and on server

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also to preserve the same session id for different requests so users count for given error is not artificially high


server.use((req, res, next) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this part is needed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? it's needed for tracking users for errors in sentry

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the very least I don't think it should use an express middleware but use getInitialProps instead

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it won't work if next.js crashes before of while excuting getIntialProps

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I can't use getInitialProps because session id for user won't be preserved at all cases (e.g. when someone does full refresh of webpage). It would require reimplementing cookies in local storage.

const htmlPage =
!req.path.match(/^\/(_next|static)/) &&
!req.path.match(/\.(js|map)$/) &&
req.accepts('text/html', 'text/css', 'image/png') === 'text/html'

if (!htmlPage) {
next()
return
}

if (!req.cookies.sid || req.cookies.sid.length === 0) {
req.cookies.sid = uuidv4()
res.cookie('sid', req.cookies.sid)
}

next()
})

// In production we don't want to serve sourcemaps for anyone
if (!dev) {
const hasSentryToken = !!process.env.SENTRY_TOKEN
server.get(/\.map$/, (req, res, next) => {
if (hasSentryToken && req.headers['x-sentry-token'] !== process.env.SENTRY_TOKEN) {
res
.status(401)
.send(
'Authentication access token is required to access the source map.'
)
return
}
next()
})
}

server.get('*', (req, res) => handle(req, res))

// This handles errors if they are thrown before raching the app
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// This handles errors if they are thrown before raching the app
// This handles errors if they are thrown before reaching the app

server.use(Sentry.Handlers.errorHandler())

server.listen(port, err => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
62 changes: 62 additions & 0 deletions examples/with-sentry/utils/sentry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const Sentry = require('@sentry/node')
const Cookie = require('js-cookie')

if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.SENTRY_RELEASE,
maxBreadcrumbs: 50,
attachStacktrace: true
})
}

function captureException (err, { req, res, extra }) {
Sentry.configureScope(scope => {
if (err.message) {
// De-duplication currently doesn't work correctly for SSR / browser errors
// so we force deduplication by error message if it is present
scope.setFingerprint([err.message])
}

if (err.statusCode) {
scope.setExtra('statusCode', err.statusCode)
}

if (res && res.statusCode) {
scope.setExtra('statusCode', res.statusCode)
}

if (process.browser) {
scope.setTag('ssr', false)

// On client-side we use js-cookie package to fetch it
const sessionId = Cookie.get('sid')
if (sessionId) {
scope.setUser({ id: sessionId })
}
} else {
scope.setTag('ssr', true)
scope.setExtra('url', req.url)
scope.setExtra('params', req.params)
scope.setExtra('query', req.query)
scope.setExtra('headers', req.headers)

// On server-side we take session cookie directly from request
if (req.cookies.sid) {
scope.setUser({ id: req.cookies.sid })
}
}

if (extra) {
Object.keys(extra).forEach(key => {
scope.setExtra(key, extra[key])
})
}
})

Sentry.captureException(err)
}

module.exports = {
captureException
}