diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/SpanFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/SpanFlyout/index.tsx index 2fdd2dd0ec6ed2..8bef7300ec80d6 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/SpanFlyout/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/SpanFlyout/index.tsx @@ -30,7 +30,7 @@ import { import { px, unit } from '../../../../../../../style/variables'; // @ts-ignore -import Stacktrace from '../../../../../../shared/Stacktrace'; +import { Stacktrace } from '../../../../../../shared/Stacktrace'; import { DatabaseContext } from './DatabaseContext'; import { HttpContext } from './HttpContext'; @@ -85,6 +85,10 @@ export function SpanFlyout({ const dbContext = span.context.db; const httpContext = span.context.http; const tagContext = span.context.tags; + const tags = keys(tagContext).map(key => ({ + key, + value: get(tagContext, key) + })); return ( @@ -140,10 +144,7 @@ export function SpanFlyout({ }, { field: 'value' } ]} - items={keys(tagContext).map(key => ({ - key, - value: get(tagContext, key) - }))} + items={tags} /> ) diff --git a/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/CodePreview.test.js b/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/CodePreview.test.js index fa2f611e6da981..96a0e778a292e1 100644 --- a/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/CodePreview.test.js +++ b/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/CodePreview.test.js @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import CodePreview from '../index'; +import { CodePreview } from '../index'; import props from './props.json'; import { toJson } from '../../../../utils/testHelpers'; diff --git a/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/__snapshots__/CodePreview.test.js.snap b/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/__snapshots__/CodePreview.test.js.snap index dd6009581ecc4f..057be2e5e97c5b 100644 --- a/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/__snapshots__/CodePreview.test.js.snap +++ b/x-pack/plugins/apm/public/components/shared/CodePreview/__test__/__snapshots__/CodePreview.test.js.snap @@ -1,6 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CodePreview should render with data 1`] = ` +.c2 { + color: #999999; + padding: 8px; + font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; +} + +.c3 { + font-weight: bold; + color: #000000; +} + .c4 { position: relative; border-radius: 0 0 5px 5px; @@ -85,16 +96,10 @@ exports[`CodePreview should render with data 1`] = ` } .c1 { - color: #999999; - padding: 8px; border-bottom: 1px solid #d9d9d9; border-radius: 5px 5px 0 0; } -.c3 { - font-weight: bold; -} - .c0 { margin: 0 0 24px 0; position: relative; @@ -104,36 +109,35 @@ exports[`CodePreview should render with data 1`] = ` background: #f5f5f5; } -.c0 .c2 { - color: #000000; -} -
- - server/coffee.js - - in - - - <anonymous> - - at - - - line - 17 - + + server/coffee.js + + in + + + <anonymous> + + at + + line + 17 + +
` margin: 0 0 ${px(units.plus)} 0; position: relative; font-family: ${fontFamilyCode}; border: 1px solid ${colors.gray4}; border-radius: ${borderRadius}; background: ${props => (props.isLibraryFrame ? colors.white : colors.gray5)}; - - ${FileDetails} { - ${props => (!props.hasContext ? 'border-bottom: 0' : null)}; - } - - ${FileDetail} { - color: ${props => (props.isLibraryFrame ? colors.gray1 : colors.black)}; - } `; -class CodePreview extends PureComponent { - state = { +interface Props { + isLibraryFrame?: boolean; + codeLanguage?: string; + stackframe: Stackframe; +} + +export class CodePreview extends PureComponent { + public state = { variablesVisible: false }; - toggleVariables = () => + public toggleVariables = () => this.setState(() => { return { variablesVisible: !this.state.variablesVisible }; }); - render() { + public render() { const { stackframe, codeLanguage, isLibraryFrame } = this.props; - const hasContext = !( - isEmpty(stackframe.context) && isEmpty(stackframe.line.context) - ); const hasVariables = !isEmpty(stackframe.vars); return ( - - - {stackframe.filename} in{' '} - {stackframe.function} at{' '} - line {stackframe.line.number} - - - {hasContext && ( - + + - )} + + + {hasVariables && ( = ({ + stackframe, + isLibraryFrame = false +}) => { + const FileDetail = isLibraryFrame + ? LibraryFrameFileDetail + : AppFrameFileDetail; + const lineNumber: number = get(stackframe, 'line.number'); + return ( + + {stackframe.filename} in{' '} + {stackframe.function} + {lineNumber > 0 && ( + + {' at '} + line {stackframe.line.number} + + )} + + ); +}; + +export { FrameHeading }; diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryFrames.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryFrames.tsx new file mode 100644 index 00000000000000..b4511aa99e9bd8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryFrames.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLink } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import { Stackframe } from '../../../../typings/APMDoc'; +import { px, units } from '../../../style/variables'; +import { CodePreview } from '../../shared/CodePreview'; +// @ts-ignore +import { Ellipsis } from '../../shared/Icons'; +import { FrameHeading } from './FrameHeading'; +import { hasSourceLines } from './stacktraceUtils'; + +const LibraryFrameToggle = styled.div` + margin: 0 0 ${px(units.plus)} 0; + user-select: none; +`; + +interface Props { + visible?: boolean; + stackframes: Stackframe[]; + codeLanguage?: string; + onClick: () => void; +} + +export const LibraryFrames: React.SFC = ({ + visible, + stackframes, + codeLanguage, + onClick +}) => { + return ( +
+ + + {' '} + {stackframes.length} library frames + + + +
+ {visible && + stackframes.map( + (stackframe, i) => + hasSourceLines(stackframe) ? ( + + ) : ( + + ) + )} +
+
+ ); +}; diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraceUtils.test.ts b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraceUtils.test.ts new file mode 100644 index 00000000000000..217b94a3548e3d --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraceUtils.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Stackframe } from '../../../../../typings/APMDoc'; +import { getCollapsedLibraryFrames, hasSourceLines } from '../stacktraceUtils'; +import stacktracesMock from './stacktraces.json'; + +const stackframeMockWithSource = stacktracesMock[0]; +const stackframeMockWithoutSource = stacktracesMock[1]; + +describe('stactraceUtils', () => { + describe('getCollapsedLibraryFrames', () => { + it('should collapse the library frames into a set of grouped, nested stackframes', () => { + const result = getCollapsedLibraryFrames(stacktracesMock as Stackframe[]); + expect(result.length).toBe(3); + expect(result[0].libraryFrame).toBe(false); + expect(result[1].libraryFrame).toBe(true); + expect(result[1].stackframes).toHaveLength(2); // two nested stackframes + expect(result[2].libraryFrame).toBe(false); + }); + }); + + describe('hasSourceLines', () => { + it('should return true given a stackframe with a source context', () => { + const result = hasSourceLines(stackframeMockWithSource as Stackframe); + expect(result).toBe(true); + }); + it('should return false given a stackframe with no source context', () => { + const result = hasSourceLines(stackframeMockWithoutSource as Stackframe); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json new file mode 100644 index 00000000000000..5723d2e4ce4aea --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json @@ -0,0 +1,67 @@ +[ + { + "function": "", + "libraryFrame": false, + "excludeFromGrouping": false, + "context": { + "pre": ["", "app.get('/log-error', function (req, res) {"], + "post": [ + " if (err) {", + " res.status(500).send('could not capture error: ' + err.message)" + ] + }, + "line": { + "number": 17, + "context": " apm.captureError(new Error('foo'), function (err) {" + }, + "filename": "server/coffee.js", + "absPath": "/app/server/coffee.js" + }, + { + "function": "get", + "libraryFrame": true, + "excludeFromGrouping": false, + "context": {}, + "line": { + "number": 123, + "context": "" + }, + "filename": "express/get.js", + "absPath": "/node_modules/express/get.js" + }, + { + "function": "use", + "libraryFrame": true, + "excludeFromGrouping": false, + "context": { + "pre": ["", "app.use('/log-error', function (req, res) {"], + "post": [ + " if (err) {", + " res.status(500).send('could not capture error: ' + err.message)" + ] + }, + "line": { + "number": 234, + "context": " apm.captureError(new Error('foo'), function (err) {" + }, + "filename": "express/use.js", + "absPath": "/node_modules/express/use.js" + }, + { + "function": "handleCoffee", + "libraryFrame": false, + "excludeFromGrouping": false, + "context": { + "pre": ["", ""], + "post": [ + " reply(getCoffee(req.id));" + ] + }, + "line": { + "number": 45, + "context": " handleCoffee(req => {" + }, + "filename": "server/handleCoffee.js", + "absPath": "/app/server/handleCoffee.js" + } +] diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/index.js b/x-pack/plugins/apm/public/components/shared/Stacktrace/index.js deleted file mode 100644 index b7a7f5f567f7bc..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/index.js +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { PureComponent } from 'react'; -import styled from 'styled-components'; -import { isEmpty, get } from 'lodash'; -import CodePreview from '../../shared/CodePreview'; -import { Ellipsis } from '../../shared/Icons'; -import { units, px } from '../../../style/variables'; -import { EmptyMessage } from '../../shared/EmptyMessage'; -import { EuiLink, EuiTitle } from '@elastic/eui'; - -const LibraryFrameToggle = styled.div` - margin: 0 0 ${px(units.plus)} 0; - user-select: none; -`; - -const LibraryFrames = styled.div``; - -function getCollapsedLibraryFrames(stackframes) { - return stackframes.reduce((acc, stackframe) => { - if (!stackframe.libraryFrame) { - return [...acc, stackframe]; - } - - // current stackframe is library frame - const prevItem = acc[acc.length - 1]; - if (!get(prevItem, 'libraryFrame')) { - return [...acc, { libraryFrame: true, stackframes: [stackframe] }]; - } - - return [ - ...acc.slice(0, -1), - { ...prevItem, stackframes: [...prevItem.stackframes, stackframe] } - ]; - }, []); -} - -class Stacktrace extends PureComponent { - state = { - libraryframes: {} - }; - - componentDidMount() { - if (!this.props.stackframes) { - // Don't do anything, if there are no stackframes - return false; - } - - const hasAnyAppFrames = this.props.stackframes.some( - frame => !frame.libraryFrame - ); - - if (!hasAnyAppFrames) { - // If there are no app frames available, always show the only existing group - this.setState({ libraryframes: { 0: true } }); - } - } - - toggle = i => - this.setState(({ libraryframes }) => { - return { libraryframes: { ...libraryframes, [i]: !libraryframes[i] } }; - }); - - render() { - const { stackframes = [], codeLanguage } = this.props; - - if (isEmpty(stackframes)) { - return ; - } - - return ( -
- -

Stack traces

-
- {getCollapsedLibraryFrames(stackframes).map((item, i) => { - if (!item.libraryFrame) { - return ( - - ); - } - - return ( - this.toggle(i)} - /> - ); - })} -
- ); - } -} - -function Libraryframes({ visible, stackframes, codeLanguage, onClick }) { - return ( -
- - - {' '} - {stackframes.length} library frames - - - - - {visible && - stackframes.map((stackframe, i) => ( - - ))} - -
- ); -} - -export default Stacktrace; diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx new file mode 100644 index 00000000000000..e272d67680c6ff --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { PureComponent } from 'react'; +import { CodePreview } from '../../shared/CodePreview'; +import { EmptyMessage } from '../../shared/EmptyMessage'; +// @ts-ignore +import { Ellipsis } from '../../shared/Icons'; +import { FrameHeading } from './FrameHeading'; +import { LibraryFrames } from './LibraryFrames'; +import { + getCollapsedLibraryFrames, + hasSourceLines, + StackframeCollapsed +} from './stacktraceUtils'; + +interface Props { + stackframes?: StackframeCollapsed[]; + codeLanguage?: string; +} + +interface StateLibraryframes { + [i: number]: boolean; +} + +interface State { + libraryframes: StateLibraryframes; +} + +export class Stacktrace extends PureComponent { + public state = { + libraryframes: {} + }; + + public componentDidMount() { + if (!this.props.stackframes) { + // Don't do anything, if there are no stackframes + return false; + } + + const hasAnyAppFrames = this.props.stackframes.some( + frame => !frame.libraryFrame + ); + + if (!hasAnyAppFrames) { + // If there are no app frames available, always show the only existing group + this.setState({ libraryframes: { 0: true } }); + } + } + + public toggle = (i: number) => + this.setState(({ libraryframes }) => { + return { libraryframes: { ...libraryframes, [i]: !libraryframes[i] } }; + }); + + public render() { + const { stackframes = [], codeLanguage } = this.props; + const { libraryframes } = this.state as State; + + if (isEmpty(stackframes)) { + return ; + } + + return ( +
+ +

Stack traces

+
+ {getCollapsedLibraryFrames(stackframes).map((item, i) => { + if (!item.libraryFrame) { + if (hasSourceLines(item)) { + return ( + + ); + } + return ; + } + + return ( + this.toggle(i)} + /> + ); + })} +
+ ); + } +} diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/stacktraceUtils.ts b/x-pack/plugins/apm/public/components/shared/Stacktrace/stacktraceUtils.ts new file mode 100644 index 00000000000000..37df3f6c2162b0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/stacktraceUtils.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, isEmpty } from 'lodash'; +import { Stackframe } from '../../../../typings/APMDoc'; + +export interface StackframeCollapsed extends Stackframe { + libraryFrame?: boolean; + stackframes?: Stackframe[]; +} + +export function getCollapsedLibraryFrames( + stackframes: Stackframe[] +): StackframeCollapsed[] { + return stackframes.reduce((acc: any, stackframe: StackframeCollapsed) => { + if (!stackframe.libraryFrame) { + return [...acc, stackframe]; + } + + // current stackframe is library frame + const prevItem: StackframeCollapsed = acc[acc.length - 1]; + if (!get(prevItem, 'libraryFrame')) { + return [...acc, { libraryFrame: true, stackframes: [stackframe] }]; + } + + return [ + ...acc.slice(0, -1), + { + ...prevItem, + stackframes: prevItem.stackframes + ? [...prevItem.stackframes, stackframe] + : [stackframe] + } + ]; + }, []); +} + +export function hasSourceLines(stackframe: Stackframe) { + return ( + !isEmpty(stackframe.context) || !isEmpty(get(stackframe, 'line.context')) + ); +}