[APM] Error stack trace improvements (#49254) (#51072)

This commit is contained in:
Nathan L Smith 2019-11-20 00:19:17 -06:00 committed by Søren Louv-Jansen
parent e488f5d87a
commit f385912bef
20 changed files with 1475 additions and 1169 deletions

View file

@ -17,7 +17,7 @@ export interface ErrorTab {
export const logStacktraceTab: ErrorTab = {
key: 'log_stacktrace',
label: i18n.translate('xpack.apm.propertiesTable.tabs.logStacktraceLabel', {
defaultMessage: 'Log stacktrace'
defaultMessage: 'Log stack trace'
})
};
@ -26,7 +26,7 @@ export const exceptionStacktraceTab: ErrorTab = {
label: i18n.translate(
'xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel',
{
defaultMessage: 'Exception stacktrace'
defaultMessage: 'Exception stack trace'
}
)
};

View file

@ -0,0 +1,41 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { ExceptionStacktrace } from './ExceptionStacktrace';
describe('ExceptionStacktrace', () => {
describe('render', () => {
it('renders', () => {
const props = { exceptions: [] };
expect(() =>
shallow(<ExceptionStacktrace {...props} />)
).not.toThrowError();
});
describe('with a stack trace', () => {
it('renders the stack trace', () => {
const props = { exceptions: [{}] };
expect(
shallow(<ExceptionStacktrace {...props} />).find('Stacktrace')
).toHaveLength(1);
});
});
describe('with more than one stack trace', () => {
it('renders a cause stack trace', () => {
const props = { exceptions: [{}, {}] };
expect(
shallow(<ExceptionStacktrace {...props} />).find('CauseStacktrace')
).toHaveLength(1);
});
});
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 from 'react';
import { EuiTitle } from '@elastic/eui';
import { idx } from '@kbn/elastic-idx/target';
import { Exception } from '../../../../../typings/es_schemas/raw/ErrorRaw';
import { Stacktrace } from '../../../shared/Stacktrace';
import { CauseStacktrace } from '../../../shared/Stacktrace/CauseStacktrace';
interface ExceptionStacktraceProps {
codeLanguage?: string;
exceptions: Exception[];
}
export function ExceptionStacktrace({
codeLanguage,
exceptions
}: ExceptionStacktraceProps) {
const title = idx(exceptions, _ => _[0].message);
return (
<>
<EuiTitle size="xs">
<h4>{title}</h4>
</EuiTitle>
{exceptions.map((ex, index) => {
return index === 0 ? (
<Stacktrace
key={index}
stackframes={ex.stacktrace}
codeLanguage={codeLanguage}
/>
) : (
<CauseStacktrace
codeLanguage={codeLanguage}
key={index}
id={index.toString()}
message={ex.message}
stackframes={ex.stacktrace}
/>
);
})}
</>
);
}

View file

@ -46,7 +46,7 @@ exports[`DetailView should render TabContent 1`] = `
currentTab={
Object {
"key": "exception_stacktrace",
"label": "Exception stacktrace",
"label": "Exception stack trace",
}
}
error={
@ -71,7 +71,7 @@ exports[`DetailView should render tabs 1`] = `
key="exception_stacktrace"
onClick={[Function]}
>
Exception stacktrace
Exception stack trace
</EuiTab>
<EuiTab
isSelected={false}

View file

@ -40,6 +40,7 @@ import { TimestampTooltip } from '../../../shared/TimestampTooltip';
import { HttpInfoSummaryItem } from '../../../shared/Summary/HttpInfoSummaryItem';
import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink';
import { UserAgentSummaryItem } from '../../../shared/Summary/UserAgentSummaryItem';
import { ExceptionStacktrace } from './ExceptionStacktrace';
const HeaderContainer = styled.div`
display: flex;
@ -180,7 +181,7 @@ export function DetailView({ errorGroup, urlParams, location }: Props) {
);
}
export function TabContent({
function TabContent({
error,
currentTab
}: {
@ -188,7 +189,7 @@ export function TabContent({
currentTab: ErrorTab;
}) {
const codeLanguage = idx(error, _ => _.service.language.name);
const excStackframes = idx(error, _ => _.error.exception[0].stacktrace);
const exceptions = idx(error, _ => _.error.exception) || [];
const logStackframes = idx(error, _ => _.error.log.stacktrace);
switch (currentTab.key) {
@ -198,7 +199,10 @@ export function TabContent({
);
case exceptionStacktraceTab.key:
return (
<Stacktrace stackframes={excStackframes} codeLanguage={codeLanguage} />
<ExceptionStacktrace
codeLanguage={codeLanguage}
exceptions={exceptions}
/>
);
default:
return <ErrorMetadata error={error} />;

View file

@ -4,12 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiLink } from '@elastic/eui';
import { EuiIcon, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Fragment, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { px, units } from '../../../../../../../style/variables';
import { Ellipsis } from '../../../../../../shared/Icons';
const ToggleButtonContainer = styled.div`
margin-top: ${px(units.half)};
@ -55,7 +54,13 @@ export const TruncateHeightSection: React.SFC<Props> = ({
setIsOpen(!isOpen);
}}
>
<Ellipsis horizontal={!isOpen} />{' '}
<EuiIcon
style={{
transition: 'transform 0.1s',
transform: `rotate(${isOpen ? 90 : 0}deg)`
}}
type="arrowRight"
/>{' '}
{isOpen
? i18n.translate('xpack.apm.toggleHeight.showLessButtonLabel', {
defaultMessage: 'Show fewer lines'

View file

@ -1,20 +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 { EuiIcon } from '@elastic/eui';
import React from 'react';
export function Ellipsis({ horizontal }: { horizontal: boolean }) {
return (
<EuiIcon
style={{
transition: 'transform 0.1s',
transform: `rotate(${horizontal ? 90 : 0}deg)`
}}
type="arrowRight"
/>
);
}

View file

@ -0,0 +1,52 @@
/*
* 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 from 'react';
import { mount, shallow } from 'enzyme';
import { CauseStacktrace } from './CauseStacktrace';
describe('CauseStacktrace', () => {
describe('render', () => {
describe('with no stack trace', () => {
it('renders without the accordion', () => {
const props = { id: 'testId', message: 'testMessage' };
expect(
mount(<CauseStacktrace {...props} />).find('CausedBy')
).toHaveLength(1);
});
});
describe('with no message and a stack trace', () => {
it('says "Caused by …', () => {
const props = {
id: 'testId',
stackframes: [{ filename: 'testFilename', line: { number: 1 } }]
};
expect(
mount(<CauseStacktrace {...props} />)
.find('EuiTitle span')
.text()
).toEqual('…');
});
});
describe('with a message and a stack trace', () => {
it('renders with the accordion', () => {
const props = {
id: 'testId',
message: 'testMessage',
stackframes: [{ filename: 'testFilename', line: { number: 1 } }]
};
expect(
shallow(<CauseStacktrace {...props} />).find('Styled(EuiAccordion)')
).toHaveLength(1);
});
});
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 from 'react';
import styled from 'styled-components';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiTitle } from '@elastic/eui';
import { px, unit } from '../../../style/variables';
import { Stacktrace } from '.';
import { IStackframe } from '../../../../typings/es_schemas/raw/fields/Stackframe';
// @ts-ignore Styled Components has trouble inferring the types of the default props here.
const Accordion = styled(EuiAccordion)`
border-top: ${theme.euiBorderThin};
`;
const CausedByContainer = styled('h5')`
padding: ${theme.spacerSizes.s} 0;
`;
const CausedByHeading = styled('span')`
color: ${theme.textColors.subdued};
display: block;
font-size: ${theme.euiFontSizeXS};
font-weight: ${theme.euiFontWeightBold};
text-transform: uppercase;
`;
const FramesContainer = styled('div')`
padding-left: ${px(unit)};
`;
function CausedBy({ message }: { message: string }) {
return (
<CausedByContainer>
<CausedByHeading>
{i18n.translate(
'xpack.apm.stacktraceTab.causedByFramesToogleButtonLabel',
{
defaultMessage: 'Caused By'
}
)}
</CausedByHeading>
<EuiTitle size="xxs">
<span>{message}</span>
</EuiTitle>
</CausedByContainer>
);
}
interface CauseStacktraceProps {
codeLanguage?: string;
id: string;
message?: string;
stackframes?: IStackframe[];
}
export function CauseStacktrace({
codeLanguage,
id,
message = '…',
stackframes = []
}: CauseStacktraceProps) {
if (stackframes.length === 0) {
return <CausedBy message={message} />;
}
return (
<Accordion buttonContent={<CausedBy message={message} />} id={id}>
<FramesContainer>
<Stacktrace stackframes={stackframes} codeLanguage={codeLanguage} />
</FramesContainer>
</Accordion>
);
}

View file

@ -32,7 +32,7 @@ registerLanguage('ruby', ruby);
const ContextContainer = styled.div`
position: relative;
border-radius: 0 0 ${borderRadius} ${borderRadius};
border-radius: ${borderRadius};
`;
const LINE_HEIGHT = units.eighth * 9;
@ -49,7 +49,7 @@ const LineNumberContainer = styled.div<{ isLibraryFrame: boolean }>`
position: absolute;
top: 0;
left: 0;
border-radius: 0 0 0 ${borderRadius};
border-radius: ${borderRadius};
background: ${props =>
props.isLibraryFrame
? theme.euiColorEmptyShade

View file

@ -12,16 +12,17 @@ import { IStackframe } from '../../../../typings/es_schemas/raw/fields/Stackfram
import { fontFamilyCode, fontSize, px, units } from '../../../style/variables';
const FileDetails = styled.div`
color: ${theme.euiColorMediumShade};
padding: ${px(units.half)};
color: ${theme.euiColorDarkShade};
padding: ${px(units.half)} 0;
font-family: ${fontFamilyCode};
font-size: ${fontSize};
`;
const LibraryFrameFileDetail = styled.span`
color: ${theme.euiColorDarkShade};
`;
const AppFrameFileDetail = styled.span`
font-weight: bold;
color: ${theme.euiColorFullShade};
`;

View file

@ -1,88 +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 { EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { IStackframe } from '../../../../typings/es_schemas/raw/fields/Stackframe';
import { Ellipsis } from '../../shared/Icons';
import { Stackframe } from './Stackframe';
const LibraryFrameToggle = styled.div`
user-select: none;
`;
interface Props {
stackframes: IStackframe[];
codeLanguage?: string;
initialVisiblity: boolean;
}
interface State {
isVisible: boolean;
}
export class LibraryStackFrames extends React.Component<Props, State> {
public state = {
isVisible: this.props.initialVisiblity
};
public onClick = () => {
this.setState(({ isVisible }) => ({ isVisible: !isVisible }));
};
public render() {
const { stackframes, codeLanguage } = this.props;
const { isVisible } = this.state;
if (stackframes.length === 0) {
return null;
}
if (stackframes.length === 1) {
return (
<Stackframe
isLibraryFrame
codeLanguage={codeLanguage}
stackframe={stackframes[0]}
/>
);
}
return (
<div>
<LibraryFrameToggle>
<EuiLink onClick={this.onClick}>
<Ellipsis horizontal={isVisible} />{' '}
{i18n.translate(
'xpack.apm.stacktraceTab.libraryFramesToogleButtonLabel',
{
defaultMessage: '{stackframesLength} library frames',
values: { stackframesLength: stackframes.length }
}
)}
</EuiLink>
</LibraryFrameToggle>
<div>
{isVisible && (
<Fragment>
<EuiSpacer size="m" />
{stackframes.map((stackframe, i) => (
<Stackframe
key={i}
isLibraryFrame
codeLanguage={codeLanguage}
stackframe={stackframe}
/>
))}
</Fragment>
)}
</div>
</div>
);
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { LibraryStacktrace } from './LibraryStacktrace';
describe('LibraryStacktrace', () => {
describe('render', () => {
describe('with no stack frames', () => {
it('renders null', () => {
const props = { id: 'testId', stackframes: [] };
expect(shallow(<LibraryStacktrace {...props} />).html()).toBeNull();
});
});
describe('with stack frames', () => {
it('renders an accordion', () => {
const props = {
id: 'testId',
stackframes: [{ filename: 'testFilename', line: { number: 1 } }]
};
expect(
shallow(<LibraryStacktrace {...props} />).find('EuiAccordion')
).toHaveLength(1);
});
});
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { EuiAccordion } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import styled from 'styled-components';
import { IStackframe } from '../../../../typings/es_schemas/raw/fields/Stackframe';
import { Stackframe } from './Stackframe';
import { px, unit } from '../../../style/variables';
const FramesContainer = styled('div')`
padding-left: ${px(unit)};
`;
interface Props {
codeLanguage?: string;
stackframes: IStackframe[];
id: string;
}
export function LibraryStacktrace({ codeLanguage, id, stackframes }: Props) {
if (stackframes.length === 0) {
return null;
}
return (
<EuiAccordion
buttonContent={i18n.translate(
'xpack.apm.stacktraceTab.libraryFramesToogleButtonLabel',
{
defaultMessage:
'{count, plural, one {# library frame} other {# library frames}}',
values: { count: stackframes.length }
}
)}
id={id}
>
<FramesContainer>
{stackframes.map((stackframe, i) => (
<Stackframe
key={i}
id={i.toString(10)}
isLibraryFrame
codeLanguage={codeLanguage}
stackframe={stackframe}
/>
))}
</FramesContainer>
</EuiAccordion>
);
}

View file

@ -7,6 +7,7 @@
import theme from '@elastic/eui/dist/eui_theme_light.json';
import React from 'react';
import styled from 'styled-components';
import { EuiAccordion } from '@elastic/eui';
import {
IStackframe,
IStackframeWithLineContext
@ -16,16 +17,11 @@ import {
fontFamilyCode,
fontSize
} from '../../../style/variables';
import { FrameHeading } from '../Stacktrace/FrameHeading';
import { FrameHeading } from './FrameHeading';
import { Context } from './Context';
import { Variables } from './Variables';
const CodeHeader = styled.div`
border-bottom: 1px solid ${theme.euiColorLightShade};
border-radius: ${borderRadius} ${borderRadius} 0 0;
`;
const Container = styled.div<{ isLibraryFrame: boolean }>`
const ContextContainer = styled.div<{ isLibraryFrame: boolean }>`
position: relative;
font-family: ${fontFamilyCode};
font-size: ${fontSize};
@ -40,12 +36,16 @@ const Container = styled.div<{ isLibraryFrame: boolean }>`
interface Props {
stackframe: IStackframe;
codeLanguage?: string;
id: string;
initialIsOpen?: boolean;
isLibraryFrame?: boolean;
}
export function Stackframe({
stackframe,
codeLanguage,
id,
initialIsOpen = false,
isLibraryFrame = false
}: Props) {
if (!hasLineContext(stackframe)) {
@ -55,19 +55,22 @@ export function Stackframe({
}
return (
<Container isLibraryFrame={isLibraryFrame}>
<CodeHeader>
<EuiAccordion
buttonContent={
<FrameHeading stackframe={stackframe} isLibraryFrame={isLibraryFrame} />
</CodeHeader>
<Context
stackframe={stackframe}
codeLanguage={codeLanguage}
isLibraryFrame={isLibraryFrame}
/>
}
id={id}
initialIsOpen={initialIsOpen}
>
<ContextContainer isLibraryFrame={isLibraryFrame}>
<Context
stackframe={stackframe}
codeLanguage={codeLanguage}
isLibraryFrame={isLibraryFrame}
/>
</ContextContainer>
<Variables vars={stackframe.vars} />
</Container>
</EuiAccordion>
);
}

View file

@ -16,7 +16,6 @@ import { flattenObject } from '../../../utils/flattenObject';
const VariablesContainer = styled.div`
background: ${theme.euiColorEmptyShade};
border-top: 1px solid ${theme.euiColorLightShade};
border-radius: 0 0 ${borderRadius} ${borderRadius};
padding: ${px(units.half)} ${px(unit)};
`;

View file

@ -16,7 +16,7 @@ describe('Stackframe', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
const stackframe = stacktracesMock[0];
wrapper = mount(<Stackframe stackframe={stackframe} />);
wrapper = mount(<Stackframe id="test" stackframe={stackframe} />);
});
it('should render correctly', () => {
@ -38,7 +38,7 @@ describe('Stackframe', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
const stackframe = { line: {} } as IStackframe;
wrapper = mount(<Stackframe stackframe={stackframe} />);
wrapper = mount(<Stackframe id="test" stackframe={stackframe} />);
});
it('should render only FrameHeading', () => {
@ -55,7 +55,7 @@ describe('Stackframe', () => {
it('should respect isLibraryFrame', () => {
const stackframe = { line: {} } as IStackframe;
const wrapper = shallow(
<Stackframe stackframe={stackframe} isLibraryFrame />
<Stackframe id="test" stackframe={stackframe} isLibraryFrame />
);
expect(wrapper.find('FrameHeading').prop('isLibraryFrame')).toBe(true);
});

View file

@ -10,7 +10,7 @@ import { isEmpty, last } from 'lodash';
import React, { Fragment } from 'react';
import { IStackframe } from '../../../../typings/es_schemas/raw/fields/Stackframe';
import { EmptyMessage } from '../../shared/EmptyMessage';
import { LibraryStackFrames } from './LibraryStackFrames';
import { LibraryStacktrace } from './LibraryStacktrace';
import { Stackframe } from './Stackframe';
interface Props {
@ -25,7 +25,7 @@ export function Stacktrace({ stackframes = [], codeLanguage }: Props) {
heading={i18n.translate(
'xpack.apm.stacktraceTab.noStacktraceAvailableLabel',
{
defaultMessage: 'No stacktrace available.'
defaultMessage: 'No stack trace available.'
}
)}
hideSubheading
@ -34,24 +34,21 @@ export function Stacktrace({ stackframes = [], codeLanguage }: Props) {
}
const groups = getGroupedStackframes(stackframes);
return (
<Fragment>
{groups.map((group, i) => {
// library frame
if (group.isLibraryFrame) {
const hasMultipleStackframes = group.stackframes.length > 1;
const hasLeadingSpacer = hasMultipleStackframes && i !== 0;
const hasTrailingSpacer =
hasMultipleStackframes && i !== groups.length - 1;
if (group.isLibraryFrame && groups.length > 1) {
return (
<Fragment key={i}>
{hasLeadingSpacer && <EuiSpacer size="m" />}
<LibraryStackFrames
initialVisiblity={!hasMultipleStackframes}
<EuiSpacer size="m" />
<LibraryStacktrace
id={i.toString()}
stackframes={group.stackframes}
codeLanguage={codeLanguage}
/>
{hasTrailingSpacer && <EuiSpacer size="m" />}
<EuiSpacer size="m" />
</Fragment>
);
}
@ -60,7 +57,12 @@ export function Stacktrace({ stackframes = [], codeLanguage }: Props) {
return group.stackframes.map((stackframe, idx) => (
<Fragment key={`${i}-${idx}`}>
{idx > 0 && <EuiSpacer size="m" />}
<Stackframe codeLanguage={codeLanguage} stackframe={stackframe} />
<Stackframe
codeLanguage={codeLanguage}
id={`${i}-${idx}`}
initialIsOpen={i === 0 && groups.length > 1}
stackframe={stackframe}
/>
</Fragment>
));
})}

View file

@ -21,7 +21,7 @@ interface Processor {
event: 'error';
}
interface Exception {
export interface Exception {
message?: string; // either message or type are given
type?: string;
module?: string;