Skip to content

Commit

Permalink
feat: Add @sentry/react (#2631)
Browse files Browse the repository at this point in the history
- Add @sentry/react package
- Add Profiler component that leverages Tracing integration
- Add withProfiler HOC component that uses Profiler component
  • Loading branch information
AbhiPrasad committed Jun 3, 2020
1 parent c82f359 commit a0015cc
Show file tree
Hide file tree
Showing 15 changed files with 478 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -3,6 +3,7 @@
## Unreleased

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

## 5.16.0

Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -26,7 +26,7 @@
"packages/integrations",
"packages/minimal",
"packages/node",
"packages/opentracing",
"packages/react",
"packages/types",
"packages/typescript",
"packages/utils"
Expand Down
4 changes: 4 additions & 0 deletions packages/react/.npmignore
@@ -0,0 +1,4 @@
*
!/dist/**/*
!/esm/**/*
*.tsbuildinfo
29 changes: 29 additions & 0 deletions packages/react/LICENSE
@@ -0,0 +1,29 @@
BSD 3-Clause License

Copyright (c) 2019, Sentry
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
13 changes: 13 additions & 0 deletions packages/react/README.md
@@ -0,0 +1,13 @@
<p align="center">
<a href="https://sentry.io" target="_blank" align="center">
<img src="https://sentry-brand.storage.googleapis.com/sentry-logo-black.png" width="280">
</a>
<br />
</p>

# Official Sentry SDK for ReactJS

## Links

- [Official SDK Docs](https://docs.sentry.io/quickstart/)
- [TypeDoc](http://getsentry.github.io/sentry-javascript/)
88 changes: 88 additions & 0 deletions packages/react/package.json
@@ -0,0 +1,88 @@
{
"name": "@sentry/react",
"version": "5.16.0",
"description": "Offical Sentry SDK for React.js",
"repository": "git://github.com/getsentry/sentry-javascript.git",
"homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react",
"author": "Sentry",
"license": "BSD-3-Clause",
"engines": {
"node": ">=6"
},
"main": "dist/index.js",
"module": "esm/index.js",
"types": "dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@sentry/browser": "5.16.0",
"@sentry/types": "5.16.0",
"@sentry/utils": "5.16.0",
"hoist-non-react-statics": "^3.3.2",
"tslib": "^1.9.3"
},
"peerDependencies": {
"react": "^16.0.0",
"react-dom": "^16.0.0"
},
"devDependencies": {
"@types/hoist-non-react-statics": "^3.3.1",
"@types/react": "^16.9.35",
"@types/react-test-renderer": "^16.9.2",
"jest": "^24.7.1",
"npm-run-all": "^4.1.2",
"prettier": "^1.17.0",
"prettier-check": "^2.0.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-test-renderer": "^16.13.1",
"rimraf": "^2.6.3",
"tslint": "^5.16.0",
"tslint-react": "^5.0.0",
"typescript": "^3.5.1"
},
"scripts": {
"build": "run-p build:es5 build:esm",
"build:es5": "tsc -p tsconfig.build.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build:watch": "run-p build:watch:es5 build:watch:esm",
"build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
"build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
"clean": "rimraf dist coverage build esm",
"link:yarn": "yarn link",
"lint": "run-s lint:prettier lint:tslint",
"lint:prettier": "prettier-check \"{src,test}/**/*.{ts,tsx}\"",
"lint:tslint": "tslint -t stylish -p .",
"lint:tslint:json": "tslint --format json -p . | tee lint-results.json",
"fix": "run-s fix:tslint fix:prettier",
"fix:prettier": "prettier --write \"{src,test}/**/*.{ts,tsx}\"",
"fix:tslint": "tslint --fix -t stylish -p .",
"test": "jest",
"test:watch": "jest --watch"
},
"jest": {
"collectCoverage": true,
"transform": {
"^.+\\.ts$": "ts-jest",
"^.+\\.tsx$": "ts-jest"
},
"moduleFileExtensions": [
"js",
"ts",
"tsx"
],
"testEnvironment": "jsdom",
"testMatch": [
"**/*.test.ts",
"**/*.test.tsx"
],
"globals": {
"ts-jest": {
"tsConfig": "./tsconfig.json",
"diagnostics": false
}
}
},
"sideEffects": false
}
3 changes: 3 additions & 0 deletions packages/react/src/index.ts
@@ -0,0 +1,3 @@
export * from '@sentry/browser';

export { Profiler, withProfiler } from './profiler';
119 changes: 119 additions & 0 deletions packages/react/src/profiler.tsx
@@ -0,0 +1,119 @@
import { getCurrentHub } from '@sentry/browser';
import { Integration, IntegrationClass } from '@sentry/types';
import { logger } from '@sentry/utils';
import * as hoistNonReactStatic from 'hoist-non-react-statics';
import * as React from 'react';

export const UNKNOWN_COMPONENT = 'unknown';

const TRACING_GETTER = ({
id: 'Tracing',
} as any) as IntegrationClass<Integration>;

/**
*
* Based on implementation from Preact:
* https:github.com/preactjs/preact/blob/9a422017fec6dab287c77c3aef63c7b2fef0c7e1/hooks/src/index.js#L301-L313
*
* Schedule a callback to be invoked after the browser has a chance to paint a new frame.
* Do this by combining requestAnimationFrame (rAF) + setTimeout to invoke a callback after
* the next browser frame.
*
* Also, schedule a timeout in parallel to the the rAF to ensure the callback is invoked
* even if RAF doesn't fire (for example if the browser tab is not visible)
*
* This is what we use to tell if a component activity has finished
*
*/
function afterNextFrame(callback: Function): void {
let timeout: number | undefined;
let raf: number;

const done = () => {
window.clearTimeout(timeout);
window.cancelAnimationFrame(raf);
window.setTimeout(callback);
};

raf = window.requestAnimationFrame(done);
timeout = window.setTimeout(done, 100);
}

const getInitActivity = (componentDisplayName: string): number | null => {
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);

if (tracingIntegration !== null) {
// tslint:disable-next-line:no-unsafe-any
const activity = (tracingIntegration as any).constructor.pushActivity(componentDisplayName, {
description: `<${componentDisplayName}>`,
op: 'react',
});

// tslint:disable-next-line: no-unsafe-any
return activity;
}

logger.warn(
`Unable to profile component ${componentDisplayName} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
);
return null;
};

interface ProfilerProps {
componentDisplayName?: string;
}

class Profiler extends React.Component<ProfilerProps> {
public activity: number | null;
public constructor(props: ProfilerProps) {
super(props);

const { componentDisplayName = UNKNOWN_COMPONENT } = this.props;

this.activity = getInitActivity(componentDisplayName);
}

public componentDidMount(): void {
afterNextFrame(this.finishProfile);
}

public componentWillUnmount(): void {
afterNextFrame(this.finishProfile);
}

public finishProfile = () => {
if (!this.activity) {
return;
}

const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
if (tracingIntegration !== null) {
// tslint:disable-next-line:no-unsafe-any
(tracingIntegration as any).constructor.popActivity(this.activity);
this.activity = null;
}
};

public render(): React.ReactNode {
return this.props.children;
}
}

function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>): React.FC<P> {
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;

const Wrapped: React.FC<P> = (props: P) => (
<Profiler componentDisplayName={componentDisplayName}>
<WrappedComponent {...props} />
</Profiler>
);

Wrapped.displayName = `profiler(${componentDisplayName})`;

// Copy over static methods from Wrapped component to Profiler HOC
// See: https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
hoistNonReactStatic(Wrapped, WrappedComponent);
return Wrapped;
}

export { withProfiler, Profiler };
70 changes: 70 additions & 0 deletions packages/react/test/profiler.test.tsx
@@ -0,0 +1,70 @@
import * as React from 'react';
import { create } from 'react-test-renderer';

import { UNKNOWN_COMPONENT, withProfiler } from '../src/profiler';

const mockPushActivity = jest.fn().mockReturnValue(1);
const mockPopActivity = jest.fn();

jest.mock('@sentry/browser', () => ({
getCurrentHub: () => ({
getIntegration: (_: string) => {
class MockIntegration {
public constructor(name: string) {
this.name = name;
}
public name: string;
public setupOnce: () => void = jest.fn();
public static pushActivity: () => void = mockPushActivity;
public static popActivity: () => void = mockPopActivity;
}

return new MockIntegration('test');
},
}),
}));

describe('withProfiler', () => {
it('sets displayName properly', () => {
const TestComponent = () => <h1>Hello World</h1>;

const ProfiledComponent = withProfiler(TestComponent);
expect(ProfiledComponent.displayName).toBe('profiler(TestComponent)');
});

describe('Tracing Integration', () => {
beforeEach(() => {
jest.useFakeTimers();
mockPushActivity.mockClear();
mockPopActivity.mockClear();
});

it('is called with popActivity() when unmounted', () => {
const ProfiledComponent = withProfiler(() => <h1>Hello World</h1>);

expect(mockPopActivity).toHaveBeenCalledTimes(0);

const profiler = create(<ProfiledComponent />);
profiler.unmount();

jest.runAllTimers();

expect(mockPopActivity).toHaveBeenCalledTimes(1);
expect(mockPopActivity).toHaveBeenLastCalledWith(1);
});

describe('pushActivity()', () => {
it('is called when mounted', () => {
const ProfiledComponent = withProfiler(() => <h1>Testing</h1>);

expect(mockPushActivity).toHaveBeenCalledTimes(0);
create(<ProfiledComponent />);
expect(mockPushActivity).toHaveBeenCalledTimes(1);
expect(mockPushActivity).toHaveBeenLastCalledWith(UNKNOWN_COMPONENT, {
description: `<${UNKNOWN_COMPONENT}>`,
op: 'react',
});
});
});
});
});
9 changes: 9 additions & 0 deletions packages/react/tsconfig.build.json
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"jsx": "react"
},
"include": ["src/**/*"]
}
9 changes: 9 additions & 0 deletions packages/react/tsconfig.esm.json
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.esm.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "esm",
"jsx": "react"
},
"include": ["src/**/*"]
}
9 changes: 9 additions & 0 deletions packages/react/tsconfig.json
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.build.json",
"include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.tsx", "test/**/*.tsx"],
"exclude": ["dist"],
"compilerOptions": {
"rootDir": ".",
"types": ["jest"]
}
}
11 changes: 11 additions & 0 deletions packages/react/tslint.json
@@ -0,0 +1,11 @@
{
"extends": ["@sentry/typescript/tslint", "tslint-react"],
"rules": {
"no-implicit-dependencies": [
true,
"dev"
],
"variable-name": false,
"completed-docs": false
}
}
1 change: 0 additions & 1 deletion typedoc.js
Expand Up @@ -9,7 +9,6 @@ module.exports = {
'**/dist/**/*',
'**/esm/**/*',
'**/build/**/*',
'**/packages/opentracing/**/*',
'**/packages/typescript/**/*',
'**/dangerfile.ts',
],
Expand Down

0 comments on commit a0015cc

Please sign in to comment.