Skip to content

Commit 5441b49

Browse files
calvin-codecovpriscilawebdev
authored andcommitted
feat(codecov): Add stack trace line coverage legend and better indication (#102450)
Closes https://linear.app/getsentry/issue/CCMRG-1301/polish-codecov-covereduncovered-line-visuals-in-sentry-issues Figma: https://www.figma.com/design/dHDWguapYO8kiWaMZl1GAE/CCMRG-1301?node-id=13-127&t=vLpCzeDAij3CQbHo-1 <img width="1112" height="640" alt="Screenshot 2025-10-30 at 3 25 48 PM" src="https://github.com/user-attachments/assets/eeb02a68-9a17-4b83-866a-1da8ea402cc7" /> - Adds new legend - Changes hover tooltip text for line-by-line - Added a context to intelligently handle when to show the legend based on whether any of the stack frames have code coverage data. I didn't want the legend to be there even when there was no coverage data.
1 parent 882227f commit 5441b49

File tree

8 files changed

+200
-36
lines changed

8 files changed

+200
-36
lines changed

static/app/components/events/interfaces/crashContent/exception/content.spec.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary
1616
import {textWithMarkupMatcher} from 'sentry-test/utils';
1717

1818
import {Content} from 'sentry/components/events/interfaces/crashContent/exception/content';
19+
import {LineCoverageProvider} from 'sentry/components/events/interfaces/crashContent/exception/lineCoverageContext';
1920
import ProjectsStore from 'sentry/stores/projectsStore';
2021
import {EntryType} from 'sentry/types/event';
22+
import {CodecovStatusCode, Coverage} from 'sentry/types/integrations';
2123
import {StackType, StackView} from 'sentry/types/stacktrace';
2224

2325
describe('Exception Content', () => {
@@ -471,4 +473,89 @@ describe('Exception Content', () => {
471473
expect(screen.getAllByRole('button', {name: 'View Section'})).toHaveLength(4);
472474
});
473475
});
476+
477+
describe('line coverage', () => {
478+
it('shows line coverage legend when coverage data is available', async () => {
479+
const orgWithCodecov = OrganizationFixture({codecovAccess: true});
480+
const event = EventFixture({
481+
projectID: project.id,
482+
entries: [
483+
{
484+
type: EntryType.EXCEPTION,
485+
data: {
486+
values: [
487+
{
488+
type: 'ValueError',
489+
value: 'test',
490+
stacktrace: {
491+
frames: [
492+
{
493+
function: 'func4',
494+
filename: 'file4.py',
495+
absPath: '/path/to/file4.py',
496+
lineNo: 50,
497+
context: [
498+
[48, 'def func4():'],
499+
[49, ' try:'],
500+
[50, 'raise ValueError("test")'],
501+
[51, ' except:'],
502+
[52, ' pass'],
503+
],
504+
inApp: true,
505+
},
506+
],
507+
},
508+
},
509+
],
510+
},
511+
},
512+
],
513+
});
514+
515+
MockApiClient.addMockResponse({
516+
url: `/projects/${orgWithCodecov.slug}/${project.slug}/stacktrace-coverage/`,
517+
body: {
518+
status: CodecovStatusCode.COVERAGE_EXISTS,
519+
coverageUrl: 'https://codecov.io/gh/owner/repo/file4.py',
520+
lineCoverage: [
521+
[48, Coverage.NOT_APPLICABLE],
522+
[49, Coverage.COVERED],
523+
[50, Coverage.NOT_COVERED],
524+
[51, Coverage.COVERED],
525+
[52, Coverage.PARTIAL],
526+
],
527+
},
528+
});
529+
530+
render(
531+
<LineCoverageProvider>
532+
<Content
533+
type={StackType.ORIGINAL}
534+
stackView={StackView.APP}
535+
event={event}
536+
values={event.entries[0]!.data.values}
537+
projectSlug={project.slug}
538+
newestFirst
539+
/>
540+
</LineCoverageProvider>,
541+
{
542+
organization: orgWithCodecov,
543+
deprecatedRouterMocks: true,
544+
}
545+
);
546+
547+
// The frame should be expanded
548+
const toggleButton = screen.queryByRole('button', {name: 'Toggle Context'});
549+
expect(toggleButton).toBeInTheDocument();
550+
expect(toggleButton).toHaveAttribute('data-test-id', 'toggle-button-expanded');
551+
552+
// The frame context and line coverage legend should be visible
553+
expect(await screen.findByText('def func4():')).toBeInTheDocument();
554+
expect(await screen.findByText('Line covered by tests')).toBeInTheDocument();
555+
expect(await screen.findByText('Line uncovered by tests')).toBeInTheDocument();
556+
expect(
557+
await screen.findByText('Line partially covered by tests')
558+
).toBeInTheDocument();
559+
});
560+
});
474561
});

static/app/components/events/interfaces/crashContent/exception/content.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import {Fragment, useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Button} from 'sentry/components/core/button';
5+
import {Flex} from 'sentry/components/core/layout/flex';
56
import {Tooltip} from 'sentry/components/core/tooltip';
67
import ErrorBoundary from 'sentry/components/errorBoundary';
78
import {StacktraceBanners} from 'sentry/components/events/interfaces/crashContent/exception/banners/stacktraceBanners';
9+
import {useLineCoverageContext} from 'sentry/components/events/interfaces/crashContent/exception/lineCoverageContext';
810
import {
911
prepareSourceMapDebuggerFrameInformation,
1012
useSourceMapDebuggerData,
@@ -26,6 +28,7 @@ import {
2628
} from 'sentry/views/issueDetails/streamline/foldSection';
2729
import {useIsSampleEvent} from 'sentry/views/issueDetails/utils';
2830

31+
import {LineCoverageLegend} from './lineCoverageLegend';
2932
import {Mechanism} from './mechanism';
3033
import {RelatedExceptions} from './relatedExceptions';
3134
import StackTrace from './stackTrace';
@@ -186,6 +189,7 @@ function InnerContent({
186189
? exceptionIdx === values.length - 1
187190
: exceptionIdx === 0;
188191

192+
const {hasCoverageData} = useLineCoverageContext();
189193
return (
190194
<Fragment>
191195
<StyledPre>
@@ -201,9 +205,17 @@ function InnerContent({
201205
<ToggleExceptionButton
202206
{...{hiddenExceptions, toggleRelatedExceptions, values, exception}}
203207
/>
204-
{exception.mechanism && (
205-
<Mechanism data={exception.mechanism} meta={meta?.[exceptionIdx]?.mechanism} />
206-
)}
208+
{exception.mechanism || hasCoverageData ? (
209+
<RowWrapper direction="row" justify="between">
210+
{exception.mechanism && (
211+
<Mechanism
212+
data={exception.mechanism}
213+
meta={meta?.[exceptionIdx]?.mechanism}
214+
/>
215+
)}
216+
{hasCoverageData ? <LineCoverageLegend /> : null}
217+
</RowWrapper>
218+
) : null}
207219
<RelatedExceptions
208220
mechanism={exception.mechanism}
209221
allExceptions={values}
@@ -261,9 +273,8 @@ export function Content({
261273
num_exceptions: values?.length ?? 0,
262274
});
263275

264-
// Organization context may be unavailable for the shared event view, so we
265-
// avoid using the `useOrganization` hook here and directly useContext
266-
// instead.
276+
// Organization context may be unavailable for the shared event view, so we need
277+
// to account for this possibility if we rely on the `useOrganization` hook.
267278
if (!values) {
268279
return null;
269280
}
@@ -400,3 +411,7 @@ const StyledFoldSection = styled(FoldSection)`
400411
margin-right: ${p => p.theme.space.xl};
401412
}
402413
`;
414+
415+
const RowWrapper = styled(Flex)`
416+
margin: ${p => p.theme.space.xl} 0 ${p => p.theme.space.xs} 0;
417+
`;

static/app/components/events/interfaces/crashContent/exception/index.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {Project} from 'sentry/types/project';
66
import {StackView} from 'sentry/types/stacktrace';
77

88
import {Content} from './content';
9+
import {LineCoverageProvider} from './lineCoverageContext';
910
import RawContent from './rawContent';
1011

1112
type Props = {
@@ -37,17 +38,19 @@ export function ExceptionContent({
3738
platform={event.platform}
3839
/>
3940
) : (
40-
<Content
41-
type={stackType}
42-
stackView={stackView}
43-
values={values}
44-
projectSlug={projectSlug}
45-
newestFirst={isNewestFramesFirst}
46-
event={event}
47-
groupingCurrentLevel={groupingCurrentLevel}
48-
meta={meta}
49-
threadId={threadId}
50-
/>
41+
<LineCoverageProvider>
42+
<Content
43+
type={stackType}
44+
stackView={stackView}
45+
values={values}
46+
projectSlug={projectSlug}
47+
newestFirst={isNewestFramesFirst}
48+
event={event}
49+
groupingCurrentLevel={groupingCurrentLevel}
50+
meta={meta}
51+
threadId={threadId}
52+
/>
53+
</LineCoverageProvider>
5154
)}
5255
</ErrorBoundary>
5356
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {createContext, useContext, useState} from 'react';
2+
3+
// This context is used to track whether any of the frames in the exception have line
4+
// coverage data to accurately display the line coverage legend if relevant.
5+
const LineCoverageContext = createContext<{
6+
hasCoverageData: boolean;
7+
setHasCoverageData: (hasCoverageData: boolean) => void;
8+
}>({
9+
hasCoverageData: false,
10+
setHasCoverageData: () => {},
11+
});
12+
13+
export function LineCoverageProvider({children}: {children: React.ReactNode}) {
14+
const [hasCoverageData, setHasCoverageData] = useState(false);
15+
return (
16+
<LineCoverageContext.Provider value={{hasCoverageData, setHasCoverageData}}>
17+
{children}
18+
</LineCoverageContext.Provider>
19+
);
20+
}
21+
22+
export const useLineCoverageContext = () => {
23+
return useContext(LineCoverageContext);
24+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Flex} from 'sentry/components/core/layout/flex';
4+
import {coverageText as COVERAGE_TEXT} from 'sentry/components/events/interfaces/frame/contextLineNumber';
5+
import {Coverage} from 'sentry/types/integrations';
6+
7+
export function LineCoverageLegend() {
8+
return (
9+
<Flex gap="2xl" align="center" direction="row" paddingBottom="md">
10+
<CoveredLine>{COVERAGE_TEXT[Coverage.COVERED]}</CoveredLine>
11+
<UncoveredLine>{COVERAGE_TEXT[Coverage.NOT_COVERED]}</UncoveredLine>
12+
<PartiallyCoveredLine>{COVERAGE_TEXT[Coverage.PARTIAL]}</PartiallyCoveredLine>
13+
</Flex>
14+
);
15+
}
16+
17+
const CoveredLine = styled('div')`
18+
padding-right: ${p => p.theme.space.md};
19+
border-right: 3px solid ${p => p.theme.tokens.content.success};
20+
white-space: nowrap;
21+
`;
22+
23+
const UncoveredLine = styled('div')`
24+
padding-right: ${p => p.theme.space.md};
25+
border-right: 3px solid ${p => p.theme.tokens.content.danger};
26+
white-space: nowrap;
27+
`;
28+
29+
const PartiallyCoveredLine = styled('div')`
30+
padding-right: ${p => p.theme.space.md};
31+
border-right: 3px dashed ${p => p.theme.tokens.content.warning};
32+
white-space: nowrap;
33+
`;

static/app/components/events/interfaces/crashContent/exception/mechanism.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,9 @@ export function Mechanism({data: mechanism, meta: mechanismMeta}: Props) {
9898
}
9999
});
100100

101-
return (
102-
<Wrapper>
103-
<StyledPills>{pills}</StyledPills>
104-
</Wrapper>
105-
);
101+
return <StyledPills>{pills}</StyledPills>;
106102
}
107103

108-
const Wrapper = styled('div')`
109-
margin: ${space(2)} 0 ${space(0.5)} 0;
110-
`;
111-
112104
const iconStyle = (p: {theme: Theme}) => css`
113105
transition: 0.1s linear color;
114106
color: ${p.theme.gray300};

static/app/components/events/interfaces/frame/context.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import {Fragment, useMemo} from 'react';
1+
import {Fragment, useEffect, useMemo} from 'react';
22
import styled from '@emotion/styled';
33
import keyBy from 'lodash/keyBy';
44

55
import ClippedBox from 'sentry/components/clippedBox';
6+
import {useLineCoverageContext} from 'sentry/components/events/interfaces/crashContent/exception/lineCoverageContext';
67
import {parseAssembly} from 'sentry/components/events/interfaces/utils';
78
import {IconFlag} from 'sentry/icons';
89
import {t} from 'sentry/locale';
@@ -78,6 +79,10 @@ function Context({
7879
platform,
7980
}: Props) {
8081
const organization = useOrganization();
82+
const {
83+
hasCoverageData: issueHasCoverageData,
84+
setHasCoverageData: setIssueHasCoverageData,
85+
} = useLineCoverageContext();
8186

8287
const {projects} = useProjects();
8388
const project = useMemo(
@@ -112,6 +117,12 @@ function Context({
112117
const hasCoverageData =
113118
!isLoadingCoverage && coverage?.status === CodecovStatusCode.COVERAGE_EXISTS;
114119

120+
useEffect(() => {
121+
if (hasCoverageData && !issueHasCoverageData) {
122+
setIssueHasCoverageData(true);
123+
}
124+
}, [hasCoverageData, issueHasCoverageData, setIssueHasCoverageData]);
125+
115126
const [lineCoverage = [], hasCoverage] =
116127
hasCoverageData && coverage?.lineCoverage && !!activeLineNumber! && contextLines
117128
? getLineCoverage(contextLines, coverage.lineCoverage)

static/app/components/events/interfaces/frame/contextLineNumber.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ interface Props {
1313
coverage?: Coverage;
1414
}
1515

16-
const coverageText: Record<Coverage, string | undefined> = {
17-
[Coverage.NOT_COVERED]: t('Uncovered'),
18-
[Coverage.COVERED]: t('Covered'),
19-
[Coverage.PARTIAL]: t('Partially Covered'),
16+
export const coverageText: Record<Coverage, string | undefined> = {
17+
[Coverage.NOT_COVERED]: t('Line uncovered by tests'),
18+
[Coverage.COVERED]: t('Line covered by tests'),
19+
[Coverage.PARTIAL]: t('Line partially covered by tests'),
2020
[Coverage.NOT_APPLICABLE]: undefined,
2121
};
2222
const coverageClass: Record<Coverage, string | undefined> = {
@@ -63,24 +63,23 @@ const Wrapper = styled('div')`
6363
margin-right: ${space(1.5)};
6464
background: transparent;
6565
min-width: 58px;
66-
border-right-style: solid;
67-
border-right-color: transparent;
66+
border-right: 3px solid transparent;
6867
user-select: none;
6968
}
7069
7170
&.covered .line-number {
7271
background: ${p => p.theme.green100};
72+
border-right: 3px solid ${p => p.theme.tokens.content.success};
7373
}
7474
7575
&.uncovered .line-number {
7676
background: ${p => p.theme.red100};
77-
border-right-color: ${p => p.theme.red300};
77+
border-right: 3px solid ${p => p.theme.tokens.content.danger};
7878
}
7979
8080
&.partial .line-number {
8181
background: ${p => p.theme.yellow100};
82-
border-right-style: dashed;
83-
border-right-color: ${p => p.theme.yellow300};
82+
border-right: 3px dashed ${p => p.theme.tokens.content.warning};
8483
}
8584
8685
&.active {

0 commit comments

Comments
 (0)