Skip to content

Commit 8ec8409

Browse files
authored
feat(profiling): display empty sample warning (#37707)
* fix(profiling): add empty state warning * fix(profiling): add export to empty state * feat(profile): track discarded samples (#37710)
1 parent ead9808 commit 8ec8409

File tree

12 files changed

+271
-62
lines changed

12 files changed

+271
-62
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import styled from '@emotion/styled';
2+
3+
import {ExportProfileButton} from 'sentry/components/profiling/exportProfileButton';
4+
import {t} from 'sentry/locale';
5+
import {Flamegraph} from 'sentry/utils/profiling/flamegraph';
6+
import {useParams} from 'sentry/utils/useParams';
7+
8+
interface FlamegraphWarningProps {
9+
flamegraph: Flamegraph;
10+
}
11+
12+
export function FlamegraphWarnings(props: FlamegraphWarningProps) {
13+
const params = useParams();
14+
15+
if (props.flamegraph.profile.samples.length === 0) {
16+
return (
17+
<Overlay>
18+
<p>
19+
{t(
20+
'This profile either has no samples or the total duration of frames in the profile is 0.'
21+
)}
22+
</p>
23+
<div>
24+
<ExportProfileButton
25+
variant="default"
26+
eventId={params.eventId}
27+
orgId={params.orgId}
28+
size="sm"
29+
projectId={params.projectId}
30+
title={undefined}
31+
disabled={
32+
params.eventId === undefined ||
33+
params.orgId === undefined ||
34+
params.projectId === undefined
35+
}
36+
>
37+
{t('Export Raw Profile')}
38+
</ExportProfileButton>
39+
</div>
40+
</Overlay>
41+
);
42+
}
43+
44+
return null;
45+
}
46+
47+
const Overlay = styled('div')`
48+
position: absolute;
49+
left: 0;
50+
top: 0;
51+
width: 100%;
52+
height: 100%;
53+
display: grid;
54+
grid: auto/50%;
55+
place-content: center;
56+
z-index: ${p => p.theme.zIndex.modal};
57+
text-align: center;
58+
`;

static/app/components/profiling/FrameStack/frameStack.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ const FrameStack = memo(function FrameStack(props: FrameStackProps) {
171171
/>
172172
<ListItem margin="none">
173173
<ExportProfileButton
174+
variant="xs"
174175
eventId={params.eventId}
175176
orgId={params.orgId}
176177
projectId={params.projectId}

static/app/components/profiling/exportProfileButton.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ interface ExportProfileButtonProps
1414
eventId: string | undefined;
1515
orgId: string | undefined;
1616
projectId: string | undefined;
17+
children?: React.ReactNode;
18+
variant?: 'xs' | 'default';
1719
}
1820

1921
export function ExportProfileButton(props: ExportProfileButtonProps) {
@@ -24,22 +26,32 @@ export function ExportProfileButton(props: ExportProfileButtonProps) {
2426
return p.slug === props.projectId;
2527
});
2628

27-
return (
28-
<StyledButton
29+
const href = `${api.baseUrl}/projects/${props.orgId}/${props.projectId}/profiling/raw_profiles/${props.eventId}/`;
30+
const download = `${organization.slug}_${
31+
project?.slug ?? props.projectId ?? 'unknown_project'
32+
}_${props.eventId}.profile.json`;
33+
34+
const title = t('Export Profile');
35+
36+
return props.variant === 'xs' ? (
37+
<StyledButtonSmall size="xs" title={title} href={href} download={download} {...props}>
38+
{props.children}
39+
<IconDownload size="xs" />
40+
</StyledButtonSmall>
41+
) : (
42+
<Button
43+
icon={<IconDownload />}
44+
title={title}
45+
href={href}
46+
download={download}
2947
{...props}
30-
size="xs"
31-
title={t('Export Profile')}
32-
href={`${api.baseUrl}/projects/${props.orgId}/${props.projectId}/profiling/raw_profiles/${props.eventId}/`}
33-
download={`${organization.slug}_${
34-
project?.slug ?? props.projectId ?? 'unknown_project'
35-
}_${props.eventId}.profile.json`}
3648
>
37-
<IconDownload size="xs" />
38-
</StyledButton>
49+
{props.children}
50+
</Button>
3951
);
4052
}
4153

42-
const StyledButton = styled(Button)`
54+
const StyledButtonSmall = styled(Button)`
4355
border: none;
4456
background-color: transparent;
4557
box-shadow: none;

static/app/components/profiling/flamegraph.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {FlamegraphRenderer} from 'sentry/utils/profiling/renderers/flamegraphRen
4141
import {useDevicePixelRatio} from 'sentry/utils/useDevicePixelRatio';
4242
import {useMemoWithPrevious} from 'sentry/utils/useMemoWithPrevious';
4343

44+
import {FlamegraphWarnings} from './FlamegraphWarnings';
4445
import {ProfilingFlamechartLayout} from './profilingFlamechartLayout';
4546

4647
function getTransactionConfigSpace(profiles: Profile[]): Rect {
@@ -326,6 +327,7 @@ function Flamegraph(props: FlamegraphProps): ReactElement {
326327
}
327328
flamechart={
328329
<ProfileDragDropImport onImport={props.onImport}>
330+
<FlamegraphWarnings flamegraph={flamegraph} />
329331
<FlamegraphZoomView
330332
flamegraphRenderer={flamegraphRenderer}
331333
canvasBounds={canvasBounds}

static/app/components/profiling/flamegraphZoomView.tsx

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
1+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
33
import {mat3, vec2} from 'gl-matrix';
44

@@ -734,55 +734,53 @@ function FlamegraphZoomView({
734734
const contextMenu = useContextMenu({container: flamegraphCanvasRef});
735735

736736
return (
737-
<Fragment>
738-
<CanvasContainer>
739-
<Canvas
740-
ref={canvas => setFlamegraphCanvasRef(canvas)}
741-
onMouseDown={onCanvasMouseDown}
742-
onMouseUp={onCanvasMouseUp}
743-
onMouseMove={onCanvasMouseMove}
744-
onMouseLeave={onCanvasMouseLeave}
745-
onContextMenu={contextMenu.handleContextMenu}
746-
style={{cursor: lastInteraction === 'pan' ? 'grab' : 'default'}}
747-
/>
748-
<Canvas
749-
ref={canvas => setFlamegraphOverlayCanvasRef(canvas)}
750-
style={{
751-
pointerEvents: 'none',
752-
}}
753-
/>
754-
<FlamegraphOptionsContextMenu contextMenu={contextMenu} />
755-
{flamegraphCanvas &&
756-
flamegraphRenderer &&
757-
flamegraphView &&
758-
configSpaceCursor &&
759-
hoveredNode?.frame?.name ? (
760-
<BoundTooltip
761-
bounds={canvasBounds}
762-
cursor={configSpaceCursor}
763-
flamegraphCanvas={flamegraphCanvas}
764-
flamegraphView={flamegraphView}
765-
>
766-
<HoveredFrameMainInfo>
767-
<FrameColorIndicator
768-
backgroundColor={formatColorForFrame(hoveredNode, flamegraphRenderer)}
769-
/>
770-
{flamegraphRenderer.flamegraph.formatter(hoveredNode.node.totalWeight)}{' '}
771-
{formatWeightToProfileDuration(
772-
hoveredNode.node,
773-
flamegraphRenderer.flamegraph
774-
)}{' '}
775-
{hoveredNode.frame.name}
776-
</HoveredFrameMainInfo>
777-
<HoveredFrameTimelineInfo>
778-
{flamegraphRenderer.flamegraph.timelineFormatter(hoveredNode.start)}{' '}
779-
{' \u2014 '}
780-
{flamegraphRenderer.flamegraph.timelineFormatter(hoveredNode.end)}
781-
</HoveredFrameTimelineInfo>
782-
</BoundTooltip>
783-
) : null}
784-
</CanvasContainer>
785-
</Fragment>
737+
<CanvasContainer>
738+
<Canvas
739+
ref={canvas => setFlamegraphCanvasRef(canvas)}
740+
onMouseDown={onCanvasMouseDown}
741+
onMouseUp={onCanvasMouseUp}
742+
onMouseMove={onCanvasMouseMove}
743+
onMouseLeave={onCanvasMouseLeave}
744+
onContextMenu={contextMenu.handleContextMenu}
745+
style={{cursor: lastInteraction === 'pan' ? 'grab' : 'default'}}
746+
/>
747+
<Canvas
748+
ref={canvas => setFlamegraphOverlayCanvasRef(canvas)}
749+
style={{
750+
pointerEvents: 'none',
751+
}}
752+
/>
753+
<FlamegraphOptionsContextMenu contextMenu={contextMenu} />
754+
{flamegraphCanvas &&
755+
flamegraphRenderer &&
756+
flamegraphView &&
757+
configSpaceCursor &&
758+
hoveredNode?.frame?.name ? (
759+
<BoundTooltip
760+
bounds={canvasBounds}
761+
cursor={configSpaceCursor}
762+
flamegraphCanvas={flamegraphCanvas}
763+
flamegraphView={flamegraphView}
764+
>
765+
<HoveredFrameMainInfo>
766+
<FrameColorIndicator
767+
backgroundColor={formatColorForFrame(hoveredNode, flamegraphRenderer)}
768+
/>
769+
{flamegraphRenderer.flamegraph.formatter(hoveredNode.node.totalWeight)}{' '}
770+
{formatWeightToProfileDuration(
771+
hoveredNode.node,
772+
flamegraphRenderer.flamegraph
773+
)}{' '}
774+
{hoveredNode.frame.name}
775+
</HoveredFrameMainInfo>
776+
<HoveredFrameTimelineInfo>
777+
{flamegraphRenderer.flamegraph.timelineFormatter(hoveredNode.start)}{' '}
778+
{' \u2014 '}
779+
{flamegraphRenderer.flamegraph.timelineFormatter(hoveredNode.end)}
780+
</HoveredFrameTimelineInfo>
781+
</BoundTooltip>
782+
) : null}
783+
</CanvasContainer>
786784
);
787785
}
788786

static/app/utils/profiling/profile/eventedProfile.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export class EventedProfile extends Profile {
137137
leaveFrame(_event: Frame, at: number): void {
138138
this.addWeightToFrames(at);
139139
this.addWeightsToNodes(at);
140+
this.trackSampleStats(at);
140141

141142
const leavingStackTop = this.appendOrderStack.pop();
142143

static/app/utils/profiling/profile/jsSelfProfile.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export class JSSelfProfile extends Profile {
9595
}
9696

9797
appendSample(stack: Frame[], weight: number): void {
98+
this.trackSampleStats(weight);
99+
98100
let node = this.appendOrderTree;
99101
const framesInStack: CallTreeNode[] = [];
100102

static/app/utils/profiling/profile/profile.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {lastOfArray} from 'sentry/utils';
33
import {CallTreeNode} from '../callTreeNode';
44
import {Frame} from '../frame';
55

6-
// This is a simplified port of speedscope's profile with a few simplifications and some removed functionality.
6+
interface ProfileStats {
7+
discardedSamplesCount: number;
8+
negativeSamplesCount: number;
9+
}
10+
11+
// This is a simplified port of speedscope's profile with a few simplifications and some removed functionality + some added functionality.
712
// head at commit e37f6fa7c38c110205e22081560b99cb89ce885e
813

914
// We should try and remove these as we adopt our own profile format and only rely on the sampled format.
@@ -32,6 +37,11 @@ export class Profile {
3237
samples: CallTreeNode[] = [];
3338
weights: number[] = [];
3439

40+
stats: ProfileStats = {
41+
discardedSamplesCount: 0,
42+
negativeSamplesCount: 0,
43+
};
44+
3545
constructor(
3646
duration: number,
3747
startedAt: number,
@@ -52,6 +62,16 @@ export class Profile {
5262
return new Profile(1000, 0, 1000, '', 'milliseconds', 0).build();
5363
}
5464

65+
trackSampleStats(duration: number) {
66+
// Keep track of discarded samples and ones that may have negative weights
67+
if (duration === 0) {
68+
this.stats.discardedSamplesCount++;
69+
}
70+
if (duration < 0) {
71+
this.stats.negativeSamplesCount++;
72+
}
73+
}
74+
5575
forEach(
5676
openFrame: (node: CallTreeNode, value: number) => void,
5777
closeFrame: (node: CallTreeNode, value: number) => void

static/app/utils/profiling/profile/sampledProfile.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class SampledProfile extends Profile {
4949
}
5050

5151
appendSampleWithWeight(stack: Frame[], weight: number): void {
52+
// Keep track of discarded samples and ones that may have negative weights
53+
this.trackSampleStats(weight);
54+
5255
// Ignore samples with 0 weight
5356
if (weight === 0) {
5457
return;

tests/js/spec/utils/profiling/profile/eventedProfile.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,43 @@ describe('EventedProfile', () => {
2424
expect(profile.endedAt).toBe(1000);
2525
});
2626

27+
it('tracks discarded samples', () => {
28+
const trace: Profiling.EventedProfile = {
29+
name: 'profile',
30+
startValue: 0,
31+
endValue: 1000,
32+
unit: 'milliseconds',
33+
threadID: 0,
34+
type: 'evented',
35+
events: [
36+
{type: 'O', at: 0, frame: 0},
37+
{type: 'C', at: 0, frame: 0},
38+
],
39+
};
40+
41+
const profile = EventedProfile.FromProfile(trace, createFrameIndex([{name: 'f0'}]));
42+
43+
expect(profile.stats.discardedSamplesCount).toBe(1);
44+
});
45+
46+
it('tracks negative samples', () => {
47+
const trace: Profiling.EventedProfile = {
48+
name: 'profile',
49+
startValue: 0,
50+
endValue: 1000,
51+
unit: 'milliseconds',
52+
threadID: 0,
53+
type: 'evented',
54+
events: [
55+
{type: 'O', at: 0, frame: 0},
56+
{type: 'C', at: -1, frame: 0},
57+
],
58+
};
59+
60+
const profile = EventedProfile.FromProfile(trace, createFrameIndex([{name: 'f0'}]));
61+
expect(profile.stats.negativeSamplesCount).toBe(1);
62+
});
63+
2764
it('rebuilds the stack', () => {
2865
const trace: Profiling.EventedProfile = {
2966
name: 'profile',

0 commit comments

Comments
 (0)