Skip to content

Commit a0015cc

Browse files
authored
feat: Add @sentry/react (getsentry#2631)
- Add @sentry/react package - Add Profiler component that leverages Tracing integration - Add withProfiler HOC component that uses Profiler component
1 parent c82f359 commit a0015cc

15 files changed

+478
-4
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
6+
- [react] feat: Add @sentry/react package (#2631)
67

78
## 5.16.0
89

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"packages/integrations",
2727
"packages/minimal",
2828
"packages/node",
29-
"packages/opentracing",
29+
"packages/react",
3030
"packages/types",
3131
"packages/typescript",
3232
"packages/utils"

packages/react/.npmignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!/dist/**/*
3+
!/esm/**/*
4+
*.tsbuildinfo

packages/react/LICENSE

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) 2019, Sentry
4+
All rights reserved.
5+
6+
Redistribution and use in source and binary forms, with or without
7+
modification, are permitted provided that the following conditions are met:
8+
9+
* Redistributions of source code must retain the above copyright notice, this
10+
list of conditions and the following disclaimer.
11+
12+
* Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
16+
* Neither the name of the copyright holder nor the names of its
17+
contributors may be used to endorse or promote products derived from
18+
this software without specific prior written permission.
19+
20+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

packages/react/README.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<p align="center">
2+
<a href="https://sentry.io" target="_blank" align="center">
3+
<img src="https://sentry-brand.storage.googleapis.com/sentry-logo-black.png" width="280">
4+
</a>
5+
<br />
6+
</p>
7+
8+
# Official Sentry SDK for ReactJS
9+
10+
## Links
11+
12+
- [Official SDK Docs](https://docs.sentry.io/quickstart/)
13+
- [TypeDoc](http://getsentry.github.io/sentry-javascript/)

packages/react/package.json

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
{
2+
"name": "@sentry/react",
3+
"version": "5.16.0",
4+
"description": "Offical Sentry SDK for React.js",
5+
"repository": "git://github.com/getsentry/sentry-javascript.git",
6+
"homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react",
7+
"author": "Sentry",
8+
"license": "BSD-3-Clause",
9+
"engines": {
10+
"node": ">=6"
11+
},
12+
"main": "dist/index.js",
13+
"module": "esm/index.js",
14+
"types": "dist/index.d.ts",
15+
"publishConfig": {
16+
"access": "public"
17+
},
18+
"dependencies": {
19+
"@sentry/browser": "5.16.0",
20+
"@sentry/types": "5.16.0",
21+
"@sentry/utils": "5.16.0",
22+
"hoist-non-react-statics": "^3.3.2",
23+
"tslib": "^1.9.3"
24+
},
25+
"peerDependencies": {
26+
"react": "^16.0.0",
27+
"react-dom": "^16.0.0"
28+
},
29+
"devDependencies": {
30+
"@types/hoist-non-react-statics": "^3.3.1",
31+
"@types/react": "^16.9.35",
32+
"@types/react-test-renderer": "^16.9.2",
33+
"jest": "^24.7.1",
34+
"npm-run-all": "^4.1.2",
35+
"prettier": "^1.17.0",
36+
"prettier-check": "^2.0.0",
37+
"react": "^16.0.0",
38+
"react-dom": "^16.0.0",
39+
"react-test-renderer": "^16.13.1",
40+
"rimraf": "^2.6.3",
41+
"tslint": "^5.16.0",
42+
"tslint-react": "^5.0.0",
43+
"typescript": "^3.5.1"
44+
},
45+
"scripts": {
46+
"build": "run-p build:es5 build:esm",
47+
"build:es5": "tsc -p tsconfig.build.json",
48+
"build:esm": "tsc -p tsconfig.esm.json",
49+
"build:watch": "run-p build:watch:es5 build:watch:esm",
50+
"build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
51+
"build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
52+
"clean": "rimraf dist coverage build esm",
53+
"link:yarn": "yarn link",
54+
"lint": "run-s lint:prettier lint:tslint",
55+
"lint:prettier": "prettier-check \"{src,test}/**/*.{ts,tsx}\"",
56+
"lint:tslint": "tslint -t stylish -p .",
57+
"lint:tslint:json": "tslint --format json -p . | tee lint-results.json",
58+
"fix": "run-s fix:tslint fix:prettier",
59+
"fix:prettier": "prettier --write \"{src,test}/**/*.{ts,tsx}\"",
60+
"fix:tslint": "tslint --fix -t stylish -p .",
61+
"test": "jest",
62+
"test:watch": "jest --watch"
63+
},
64+
"jest": {
65+
"collectCoverage": true,
66+
"transform": {
67+
"^.+\\.ts$": "ts-jest",
68+
"^.+\\.tsx$": "ts-jest"
69+
},
70+
"moduleFileExtensions": [
71+
"js",
72+
"ts",
73+
"tsx"
74+
],
75+
"testEnvironment": "jsdom",
76+
"testMatch": [
77+
"**/*.test.ts",
78+
"**/*.test.tsx"
79+
],
80+
"globals": {
81+
"ts-jest": {
82+
"tsConfig": "./tsconfig.json",
83+
"diagnostics": false
84+
}
85+
}
86+
},
87+
"sideEffects": false
88+
}

packages/react/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from '@sentry/browser';
2+
3+
export { Profiler, withProfiler } from './profiler';

packages/react/src/profiler.tsx

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { getCurrentHub } from '@sentry/browser';
2+
import { Integration, IntegrationClass } from '@sentry/types';
3+
import { logger } from '@sentry/utils';
4+
import * as hoistNonReactStatic from 'hoist-non-react-statics';
5+
import * as React from 'react';
6+
7+
export const UNKNOWN_COMPONENT = 'unknown';
8+
9+
const TRACING_GETTER = ({
10+
id: 'Tracing',
11+
} as any) as IntegrationClass<Integration>;
12+
13+
/**
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+
*
27+
*/
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);
40+
}
41+
42+
const getInitActivity = (componentDisplayName: string): number | null => {
43+
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
44+
45+
if (tracingIntegration !== null) {
46+
// tslint:disable-next-line:no-unsafe-any
47+
const activity = (tracingIntegration as any).constructor.pushActivity(componentDisplayName, {
48+
description: `<${componentDisplayName}>`,
49+
op: 'react',
50+
});
51+
52+
// tslint:disable-next-line: no-unsafe-any
53+
return activity;
54+
}
55+
56+
logger.warn(
57+
`Unable to profile component ${componentDisplayName} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
58+
);
59+
return null;
60+
};
61+
62+
interface ProfilerProps {
63+
componentDisplayName?: string;
64+
}
65+
66+
class Profiler extends React.Component<ProfilerProps> {
67+
public activity: number | null;
68+
public constructor(props: ProfilerProps) {
69+
super(props);
70+
71+
const { componentDisplayName = UNKNOWN_COMPONENT } = this.props;
72+
73+
this.activity = getInitActivity(componentDisplayName);
74+
}
75+
76+
public componentDidMount(): void {
77+
afterNextFrame(this.finishProfile);
78+
}
79+
80+
public componentWillUnmount(): void {
81+
afterNextFrame(this.finishProfile);
82+
}
83+
84+
public finishProfile = () => {
85+
if (!this.activity) {
86+
return;
87+
}
88+
89+
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
90+
if (tracingIntegration !== null) {
91+
// tslint:disable-next-line:no-unsafe-any
92+
(tracingIntegration as any).constructor.popActivity(this.activity);
93+
this.activity = null;
94+
}
95+
};
96+
97+
public render(): React.ReactNode {
98+
return this.props.children;
99+
}
100+
}
101+
102+
function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>): React.FC<P> {
103+
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;
104+
105+
const Wrapped: React.FC<P> = (props: P) => (
106+
<Profiler componentDisplayName={componentDisplayName}>
107+
<WrappedComponent {...props} />
108+
</Profiler>
109+
);
110+
111+
Wrapped.displayName = `profiler(${componentDisplayName})`;
112+
113+
// Copy over static methods from Wrapped component to Profiler HOC
114+
// See: https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
115+
hoistNonReactStatic(Wrapped, WrappedComponent);
116+
return Wrapped;
117+
}
118+
119+
export { withProfiler, Profiler };

packages/react/test/profiler.test.tsx

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as React from 'react';
2+
import { create } from 'react-test-renderer';
3+
4+
import { UNKNOWN_COMPONENT, withProfiler } from '../src/profiler';
5+
6+
const mockPushActivity = jest.fn().mockReturnValue(1);
7+
const mockPopActivity = jest.fn();
8+
9+
jest.mock('@sentry/browser', () => ({
10+
getCurrentHub: () => ({
11+
getIntegration: (_: string) => {
12+
class MockIntegration {
13+
public constructor(name: string) {
14+
this.name = name;
15+
}
16+
public name: string;
17+
public setupOnce: () => void = jest.fn();
18+
public static pushActivity: () => void = mockPushActivity;
19+
public static popActivity: () => void = mockPopActivity;
20+
}
21+
22+
return new MockIntegration('test');
23+
},
24+
}),
25+
}));
26+
27+
describe('withProfiler', () => {
28+
it('sets displayName properly', () => {
29+
const TestComponent = () => <h1>Hello World</h1>;
30+
31+
const ProfiledComponent = withProfiler(TestComponent);
32+
expect(ProfiledComponent.displayName).toBe('profiler(TestComponent)');
33+
});
34+
35+
describe('Tracing Integration', () => {
36+
beforeEach(() => {
37+
jest.useFakeTimers();
38+
mockPushActivity.mockClear();
39+
mockPopActivity.mockClear();
40+
});
41+
42+
it('is called with popActivity() when unmounted', () => {
43+
const ProfiledComponent = withProfiler(() => <h1>Hello World</h1>);
44+
45+
expect(mockPopActivity).toHaveBeenCalledTimes(0);
46+
47+
const profiler = create(<ProfiledComponent />);
48+
profiler.unmount();
49+
50+
jest.runAllTimers();
51+
52+
expect(mockPopActivity).toHaveBeenCalledTimes(1);
53+
expect(mockPopActivity).toHaveBeenLastCalledWith(1);
54+
});
55+
56+
describe('pushActivity()', () => {
57+
it('is called when mounted', () => {
58+
const ProfiledComponent = withProfiler(() => <h1>Testing</h1>);
59+
60+
expect(mockPushActivity).toHaveBeenCalledTimes(0);
61+
create(<ProfiledComponent />);
62+
expect(mockPushActivity).toHaveBeenCalledTimes(1);
63+
expect(mockPushActivity).toHaveBeenLastCalledWith(UNKNOWN_COMPONENT, {
64+
description: `<${UNKNOWN_COMPONENT}>`,
65+
op: 'react',
66+
});
67+
});
68+
});
69+
});
70+
});

packages/react/tsconfig.build.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"outDir": "dist",
6+
"jsx": "react"
7+
},
8+
"include": ["src/**/*"]
9+
}

packages/react/tsconfig.esm.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.esm.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"outDir": "esm",
6+
"jsx": "react"
7+
},
8+
"include": ["src/**/*"]
9+
}

packages/react/tsconfig.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.build.json",
3+
"include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.tsx", "test/**/*.tsx"],
4+
"exclude": ["dist"],
5+
"compilerOptions": {
6+
"rootDir": ".",
7+
"types": ["jest"]
8+
}
9+
}

packages/react/tslint.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": ["@sentry/typescript/tslint", "tslint-react"],
3+
"rules": {
4+
"no-implicit-dependencies": [
5+
true,
6+
"dev"
7+
],
8+
"variable-name": false,
9+
"completed-docs": false
10+
}
11+
}

typedoc.js

-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ module.exports = {
99
'**/dist/**/*',
1010
'**/esm/**/*',
1111
'**/build/**/*',
12-
'**/packages/opentracing/**/*',
1312
'**/packages/typescript/**/*',
1413
'**/dangerfile.ts',
1514
],

0 commit comments

Comments
 (0)