Skip to content

Commit 1f4772e

Browse files
authored
ref: Rewrite React Profiler (#2677)
* ref(react): Refactor Profiler to account for update and render spans * feat(apm): Add ability to get span from activity using `getActivitySpan`
1 parent c6a73dc commit 1f4772e

File tree

5 files changed

+366
-172
lines changed

5 files changed

+366
-172
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- [core] fix: Call `bindClient` when creating new `Hub` to make integrations work automatically (#2665)
1111
- [gatsby] feat: Add @sentry/gatsby package (#2652)
1212
- [core] ref: Rename `whitelistUrls/blacklistUrls` to `allowUrls/denyUrls`
13+
- [react] ref: Refactor Profiler to account for update and render (#2677)
14+
- [tracing] feat: Add ability to get span from activity using `getActivitySpan` (#2677)
1315

1416
## 5.17.0
1517

packages/apm/src/integrations/tracing.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// tslint:disable: max-file-line-count
12
import { Hub } from '@sentry/hub';
23
import { Event, EventProcessor, Integration, Severity, Span, SpanContext, TransactionContext } from '@sentry/types';
34
import {
@@ -817,6 +818,9 @@ export class Tracing implements Integration {
817818

818819
/**
819820
* Removes activity and finishes the span in case there is one
821+
* @param id the id of the activity being removed
822+
* @param spanData span data that can be updated
823+
*
820824
*/
821825
public static popActivity(id: number, spanData?: { [key: string]: any }): void {
822826
// The !id is on purpose to also fail with 0
@@ -866,6 +870,20 @@ export class Tracing implements Integration {
866870
}, timeout);
867871
}
868872
}
873+
874+
/**
875+
* Get span based on activity id
876+
*/
877+
public static getActivitySpan(id: number): Span | undefined {
878+
if (!id) {
879+
return undefined;
880+
}
881+
const activity = Tracing._activities[id];
882+
if (activity) {
883+
return activity.span;
884+
}
885+
return undefined;
886+
}
869887
}
870888

871889
/**

packages/react/src/index.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { addGlobalEventProcessor, SDK_VERSION } from '@sentry/browser';
22

33
function createReactEventProcessor(): void {
4-
addGlobalEventProcessor(event => {
5-
event.sdk = {
6-
...event.sdk,
7-
name: 'sentry.javascript.react',
8-
packages: [
9-
...((event.sdk && event.sdk.packages) || []),
10-
{
11-
name: 'npm:@sentry/react',
12-
version: SDK_VERSION,
13-
},
14-
],
15-
version: SDK_VERSION,
16-
};
4+
if (addGlobalEventProcessor) {
5+
addGlobalEventProcessor(event => {
6+
event.sdk = {
7+
...event.sdk,
8+
name: 'sentry.javascript.react',
9+
packages: [
10+
...((event.sdk && event.sdk.packages) || []),
11+
{
12+
name: 'npm:@sentry/react',
13+
version: SDK_VERSION,
14+
},
15+
],
16+
version: SDK_VERSION,
17+
};
1718

18-
return event;
19-
});
19+
return event;
20+
});
21+
}
2022
}
2123

2224
export * from '@sentry/browser';

packages/react/src/profiler.tsx

Lines changed: 178 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getCurrentHub } from '@sentry/browser';
2-
import { Integration, IntegrationClass } from '@sentry/types';
3-
import { logger } from '@sentry/utils';
2+
import { Integration, IntegrationClass, Span } from '@sentry/types';
3+
import { logger, timestampWithMs } from '@sentry/utils';
44
import * as hoistNonReactStatic from 'hoist-non-react-statics';
55
import * as React from 'react';
66

@@ -10,91 +10,167 @@ const TRACING_GETTER = ({
1010
id: 'Tracing',
1111
} as any) as IntegrationClass<Integration>;
1212

13+
let globalTracingIntegration: Integration | null = null;
14+
const getTracingIntegration = () => {
15+
if (globalTracingIntegration) {
16+
return globalTracingIntegration;
17+
}
18+
19+
globalTracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
20+
return globalTracingIntegration;
21+
};
22+
1323
/**
14-
*
15-
* Based on implementation from Preact:
16-
* https:github.com/preactjs/preact/blob/9a422017fec6dab287c77c3aef63c7b2fef0c7e1/hooks/src/index.js#L301-L313
17-
*
18-
* Schedule a callback to be invoked after the browser has a chance to paint a new frame.
19-
* Do this by combining requestAnimationFrame (rAF) + setTimeout to invoke a callback after
20-
* the next browser frame.
21-
*
22-
* Also, schedule a timeout in parallel to the the rAF to ensure the callback is invoked
23-
* even if RAF doesn't fire (for example if the browser tab is not visible)
24-
*
25-
* This is what we use to tell if a component activity has finished
26-
*
24+
* Warn if tracing integration not configured. Will only warn once.
2725
*/
28-
function afterNextFrame(callback: Function): void {
29-
let timeout: number | undefined;
30-
let raf: number;
31-
32-
const done = () => {
33-
window.clearTimeout(timeout);
34-
window.cancelAnimationFrame(raf);
35-
window.setTimeout(callback);
36-
};
37-
38-
raf = window.requestAnimationFrame(done);
39-
timeout = window.setTimeout(done, 100);
26+
function warnAboutTracing(name: string): void {
27+
if (globalTracingIntegration === null) {
28+
logger.warn(
29+
`Unable to profile component ${name} due to invalid Tracing Integration. Please make sure the Tracing integration is setup properly.`,
30+
);
31+
}
4032
}
4133

4234
/**
43-
* getInitActivity pushes activity based on React component mount
35+
* pushActivity creates an new react activity.
36+
* Is a no-op if Tracing integration is not valid
4437
* @param name displayName of component that started activity
4538
*/
46-
const getInitActivity = (name: string): number | null => {
47-
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
48-
49-
if (tracingIntegration !== null) {
50-
// tslint:disable-next-line:no-unsafe-any
51-
return (tracingIntegration as any).constructor.pushActivity(name, {
52-
description: `<${name}>`,
53-
op: 'react',
54-
});
39+
function pushActivity(name: string, op: string): number | null {
40+
if (globalTracingIntegration === null) {
41+
return null;
5542
}
5643

57-
logger.warn(
58-
`Unable to profile component ${name} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
59-
);
60-
return null;
61-
};
44+
// tslint:disable-next-line:no-unsafe-any
45+
return (globalTracingIntegration as any).constructor.pushActivity(name, {
46+
description: `<${name}>`,
47+
op: `react.${op}`,
48+
});
49+
}
50+
51+
/**
52+
* popActivity removes a React activity.
53+
* Is a no-op if Tracing integration is not valid.
54+
* @param activity id of activity that is being popped
55+
*/
56+
function popActivity(activity: number | null): void {
57+
if (activity === null || globalTracingIntegration === null) {
58+
return;
59+
}
60+
61+
// tslint:disable-next-line:no-unsafe-any
62+
(globalTracingIntegration as any).constructor.popActivity(activity);
63+
}
64+
65+
/**
66+
* Obtain a span given an activity id.
67+
* Is a no-op if Tracing integration is not valid.
68+
* @param activity activity id associated with obtained span
69+
*/
70+
function getActivitySpan(activity: number | null): Span | undefined {
71+
if (activity === null || globalTracingIntegration === null) {
72+
return undefined;
73+
}
74+
75+
// tslint:disable-next-line:no-unsafe-any
76+
return (globalTracingIntegration as any).constructor.getActivitySpan(activity) as Span | undefined;
77+
}
6278

6379
export type ProfilerProps = {
80+
// The name of the component being profiled.
6481
name: string;
82+
// If the Profiler is disabled. False by default. This is useful if you want to disable profilers
83+
// in certain environments.
84+
disabled?: boolean;
85+
// If time component is on page should be displayed as spans. True by default.
86+
hasRenderSpan?: boolean;
87+
// If component updates should be displayed as spans. True by default.
88+
hasUpdateSpan?: boolean;
89+
// props given to component being profiled.
90+
updateProps: { [key: string]: any };
6591
};
6692

93+
/**
94+
* The Profiler component leverages Sentry's Tracing integration to generate
95+
* spans based on component lifecycles.
96+
*/
6797
class Profiler extends React.Component<ProfilerProps> {
68-
public activity: number | null;
98+
// The activity representing how long it takes to mount a component.
99+
public mountActivity: number | null = null;
100+
// The span of the mount activity
101+
public mountSpan: Span | undefined = undefined;
102+
// The span of the render
103+
public renderSpan: Span | undefined = undefined;
104+
105+
public static defaultProps: Partial<ProfilerProps> = {
106+
disabled: false,
107+
hasRenderSpan: true,
108+
hasUpdateSpan: true,
109+
};
110+
69111
public constructor(props: ProfilerProps) {
70112
super(props);
113+
const { name, disabled = false } = this.props;
71114

72-
this.activity = getInitActivity(this.props.name);
115+
if (disabled) {
116+
return;
117+
}
118+
119+
if (getTracingIntegration()) {
120+
this.mountActivity = pushActivity(name, 'mount');
121+
} else {
122+
warnAboutTracing(name);
123+
}
73124
}
74125

75126
// If a component mounted, we can finish the mount activity.
76127
public componentDidMount(): void {
77-
afterNextFrame(this.finishProfile);
78-
}
79-
80-
// Sometimes a component will unmount first, so we make
81-
// sure to also finish the mount activity here.
82-
public componentWillUnmount(): void {
83-
afterNextFrame(this.finishProfile);
128+
this.mountSpan = getActivitySpan(this.mountActivity);
129+
popActivity(this.mountActivity);
130+
this.mountActivity = null;
84131
}
85132

86-
public finishProfile = () => {
87-
if (!this.activity) {
88-
return;
133+
public componentDidUpdate({ updateProps, hasUpdateSpan = true }: ProfilerProps): void {
134+
// Only generate an update span if hasUpdateSpan is true, if there is a valid mountSpan,
135+
// and if the updateProps have changed. It is ok to not do a deep equality check here as it is expensive.
136+
// We are just trying to give baseline clues for further investigation.
137+
if (hasUpdateSpan && this.mountSpan && updateProps !== this.props.updateProps) {
138+
// See what props haved changed between the previous props, and the current props. This is
139+
// set as data on the span. We just store the prop keys as the values could be potenially very large.
140+
const changedProps = Object.keys(updateProps).filter(k => updateProps[k] !== this.props.updateProps[k]);
141+
if (changedProps.length > 0) {
142+
// The update span is a point in time span with 0 duration, just signifying that the component
143+
// has been updated.
144+
const now = timestampWithMs();
145+
this.mountSpan.startChild({
146+
data: {
147+
changedProps,
148+
},
149+
description: `<${this.props.name}>`,
150+
endTimestamp: now,
151+
op: `react.update`,
152+
startTimestamp: now,
153+
});
154+
}
89155
}
156+
}
90157

91-
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
92-
if (tracingIntegration !== null) {
93-
// tslint:disable-next-line:no-unsafe-any
94-
(tracingIntegration as any).constructor.popActivity(this.activity);
95-
this.activity = null;
158+
// If a component is unmounted, we can say it is no longer on the screen.
159+
// This means we can finish the span representing the component render.
160+
public componentWillUnmount(): void {
161+
const { name, hasRenderSpan = true } = this.props;
162+
163+
if (this.mountSpan && hasRenderSpan) {
164+
// If we were able to obtain the spanId of the mount activity, we should set the
165+
// next activity as a child to the component mount activity.
166+
this.mountSpan.startChild({
167+
description: `<${name}>`,
168+
endTimestamp: timestampWithMs(),
169+
op: `react.render`,
170+
startTimestamp: this.mountSpan.endTimestamp,
171+
});
96172
}
97-
};
173+
}
98174

99175
public render(): React.ReactNode {
100176
return this.props.children;
@@ -103,16 +179,22 @@ class Profiler extends React.Component<ProfilerProps> {
103179

104180
/**
105181
* withProfiler is a higher order component that wraps a
106-
* component in a {@link Profiler} component.
182+
* component in a {@link Profiler} component. It is recommended that
183+
* the higher order component be used over the regular {@link Profiler} component.
107184
*
108185
* @param WrappedComponent component that is wrapped by Profiler
109-
* @param name displayName of component being profiled
186+
* @param options the {@link ProfilerProps} you can pass into the Profiler
110187
*/
111-
function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>, name?: string): React.FC<P> {
112-
const componentDisplayName = name || WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;
188+
function withProfiler<P extends object>(
189+
WrappedComponent: React.ComponentType<P>,
190+
// We do not want to have `updateProps` given in options, it is instead filled through the HOC.
191+
options?: Pick<Partial<ProfilerProps>, Exclude<keyof ProfilerProps, 'updateProps'>>,
192+
): React.FC<P> {
193+
const componentDisplayName =
194+
(options && options.name) || WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;
113195

114196
const Wrapped: React.FC<P> = (props: P) => (
115-
<Profiler name={componentDisplayName}>
197+
<Profiler {...options} name={componentDisplayName} updateProps={props}>
116198
<WrappedComponent {...props} />
117199
</Profiler>
118200
);
@@ -132,17 +214,40 @@ function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>
132214
* Requires React 16.8 or above.
133215
* @param name displayName of component being profiled
134216
*/
135-
function useProfiler(name: string): void {
136-
const [activity] = React.useState(() => getInitActivity(name));
217+
function useProfiler(
218+
name: string,
219+
options: { disabled?: boolean; hasRenderSpan?: boolean } = {
220+
disabled: false,
221+
hasRenderSpan: true,
222+
},
223+
): void {
224+
const [mountActivity] = React.useState(() => {
225+
if (options && options.disabled) {
226+
return null;
227+
}
228+
229+
if (getTracingIntegration()) {
230+
return pushActivity(name, 'mount');
231+
}
232+
233+
warnAboutTracing(name);
234+
return null;
235+
});
137236

138237
React.useEffect(() => {
139-
afterNextFrame(() => {
140-
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
141-
if (tracingIntegration !== null) {
142-
// tslint:disable-next-line:no-unsafe-any
143-
(tracingIntegration as any).constructor.popActivity(activity);
238+
const mountSpan = getActivitySpan(mountActivity);
239+
popActivity(mountActivity);
240+
241+
return () => {
242+
if (mountSpan && options.hasRenderSpan) {
243+
mountSpan.startChild({
244+
description: `<${name}>`,
245+
endTimestamp: timestampWithMs(),
246+
op: `react.render`,
247+
startTimestamp: mountSpan.endTimestamp,
248+
});
144249
}
145-
});
250+
};
146251
}, []);
147252
}
148253

0 commit comments

Comments
 (0)