Skip to content

Commit

Permalink
Add ability to get stack trace for error messages in the UI (#4184)
Browse files Browse the repository at this point in the history
  • Loading branch information
cmdcolin committed Jan 29, 2024
1 parent 7da90dc commit 6f7d995
Show file tree
Hide file tree
Showing 6 changed files with 1,649 additions and 1,417 deletions.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"rbush": "^3.0.1",
"react-error-boundary": "^4.0.3",
"serialize-error": "^8.0.0",
"source-map-js": "^1.0.2",
"svg-path-generator": "^1.1.0"
},
"peerDependencies": {
Expand Down
40 changes: 28 additions & 12 deletions packages/core/ui/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React from 'react'
import React, { Suspense, lazy, useState } from 'react'
import { Button } from '@mui/material'
import RedErrorMessageBox from './RedErrorMessageBox'

const ErrorMessageStackTraceDialog = lazy(
() => import('./ErrorMessageStackTraceDialog'),
)

function parseError(str: string) {
let snapshotError = ''
Expand Down Expand Up @@ -31,18 +37,20 @@ function parseError(str: string) {
const ErrorMessage = ({ error }: { error: unknown }) => {
const str = `${error}`
const snapshotError = parseError(str)
const [showStack, setShowStack] = useState(false)
return (
<div
style={{
padding: 4,
margin: 4,
overflow: 'auto',
maxHeight: 200,
background: '#f88',
border: '1px solid black',
}}
>
<RedErrorMessageBox>
{str.slice(0, 10000)}

{typeof error === 'object' && error && 'stack' in error ? (
<Button
style={{ float: 'right' }}
variant="contained"
onClick={() => setShowStack(!showStack)}
>
{showStack ? 'Hide stack trace' : 'Show stack trace'}
</Button>
) : null}
{snapshotError ? (
<pre
style={{
Expand All @@ -54,7 +62,15 @@ const ErrorMessage = ({ error }: { error: unknown }) => {
{JSON.stringify(JSON.parse(snapshotError), null, 2)}
</pre>
) : null}
</div>
{showStack ? (
<Suspense fallback={null}>
<ErrorMessageStackTraceDialog
error={error as Error}
onClose={() => setShowStack(false)}
/>
</Suspense>
) : null}
</RedErrorMessageBox>
)
}

Expand Down
175 changes: 175 additions & 0 deletions packages/core/ui/ErrorMessageStackTraceDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react'
import {
Button,
DialogActions,
DialogContent,
Link,
Typography,
} from '@mui/material'

import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
import copy from 'copy-to-clipboard'

// locals
import Dialog from './Dialog'
import LoadingEllipses from './LoadingEllipses'

// produce a source-map resolved stack trace
// reference code https://stackoverflow.com/a/77158517/2129219
const sourceMaps: Record<string, RawSourceMap> = {}
async function getSourceMapFromUri(uri: string) {
if (sourceMaps[uri] != undefined) {
return sourceMaps[uri]
}
const uriQuery = new URL(uri).search
const currentScriptContent = await (await fetch(uri)).text()

let mapUri =
new RegExp(/\/\/# sourceMappingURL=(.*)/).exec(currentScriptContent)?.[1] ||
''
mapUri = new URL(mapUri, uri).href + uriQuery

const map = await (await fetch(mapUri)).json()

sourceMaps[uri] = map

return map
}

async function mapStackTrace(stack: string) {
const stackLines = stack.split('\n')
const mappedStack = []

for (const line of stackLines) {
const match = new RegExp(/(.*)(http:\/\/.*):(\d+):(\d+)/).exec(line)
if (match === null) {
mappedStack.push(line)
continue
}

const uri = match[2]
const consumer = new SourceMapConsumer(await getSourceMapFromUri(uri))

const originalPosition = consumer.originalPositionFor({
line: parseInt(match[3]),
column: parseInt(match[4]),
})

if (
originalPosition.source === null ||
originalPosition.line === null ||
originalPosition.column === null
) {
mappedStack.push(line)
continue
}

mappedStack.push(
`${originalPosition.source}:${originalPosition.line}:${
originalPosition.column + 1
}`,
)
}

return mappedStack.join('\n')
}

const MAX_ERR_LEN = 10_000

// Chrome has the error message in the stacktrace, firefox doesn't
function stripMessage(trace: string, error: unknown) {
if (trace.startsWith('Error:')) {
// remove the error message, which can be very long due to mobx-state-tree
// stuff, to get just the stack trace
const err = `${error}`
return trace.slice(err.length)
} else {
return trace
}
}

export default function ErrorMessageStackTraceDialog({
error,
onClose,
}: {
onClose: () => void
error: Error
}) {
const [mappedStackTrace, setMappedStackTrace] = useState<string>()
const [secondaryError, setSecondaryError] = useState<unknown>()
const [clicked, setClicked] = useState(false)
const stackTracePreProcessed = `${error.stack}`
const errorText = `${error}`
const stackTrace = stripMessage(stackTracePreProcessed, errorText)

useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
;(async () => {
try {
const res = await mapStackTrace(stackTrace)
setMappedStackTrace(res)
} catch (e) {
console.error(e)
setMappedStackTrace(stackTrace)
setSecondaryError(e)
}
})()
}, [stackTrace])

const errorBoxText = [
secondaryError
? 'Error loading source map, showing raw stack trace below:'
: '',
errorText.length > MAX_ERR_LEN
? errorText.slice(0, MAX_ERR_LEN) + '...'
: errorText,
mappedStackTrace || 'No stack trace available',
// @ts-expect-error add version info at bottom if we are in jbrowse-web
window.JBrowseSession ? `JBrowse ${window.JBrowseSession.version}` : '',
].join('\n')
return (
<Dialog open onClose={onClose} title="Stack trace" maxWidth="xl">
<DialogContent>
<Typography>
Post a new issue with this stack trace at{' '}
<Link href="https://github.com/GMOD/jbrowse-components/issues/new/choose">
GitHub
</Link>{' '}
or send an email to{' '}
<Link href="mailto:jbrowse2dev@gmail.com">jbrowse2dev@gmail.com</Link>{' '}
</Typography>
{mappedStackTrace !== undefined ? (
<pre
style={{
background: 'lightgrey',
border: '1px solid black',
overflow: 'auto',
margin: 20,
maxHeight: 300,
}}
>
{errorBoxText}
</pre>
) : (
<LoadingEllipses />
)}
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="secondary"
onClick={() => {
copy(errorBoxText)
setClicked(true)
setTimeout(() => setClicked(false), 1000)
}}
>
{clicked ? 'Copied!' : 'Copy stack trace to clipboard'}
</Button>
<Button variant="contained" color="primary" onClick={onClose}>
Close
</Button>
</DialogActions>
</Dialog>
)
}
22 changes: 22 additions & 0 deletions packages/core/ui/RedErrorMessageBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react'

export default function RedErrorMessageBox({
children,
}: {
children: React.ReactNode
}) {
return (
<div
style={{
padding: 4,
margin: 4,
overflow: 'auto',
maxHeight: 200,
background: '#f88',
border: '1px solid black',
}}
>
{children}
</div>
)
}
2 changes: 1 addition & 1 deletion packages/core/ui/Snackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const Snackbar = observer(function ({ session }: { session: SnackbarSession }) {
session.popSnackbarMessage()
}
}
return !!latestMessage ? (
return latestMessage ? (
<MUISnackbar
open
onClose={handleClose}
Expand Down

0 comments on commit 6f7d995

Please sign in to comment.