Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add @sentry/react #2631

Merged
merged 11 commits into from Jun 3, 2020
Merged
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);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've decided to keep this library for now instead of vendoring it in. This is as it leverages functionality from react-is, https://github.com/facebook/react/tree/master/packages/react-is, which is hard to keep up to date properly (lot's of manual copy-paste, hard to know when different react types will change).

Many popular libraries use hoist-non-react-statics. See:
react-intl: https://github.com/formatjs/formatjs/blob/master/packages/react-intl/package.json#L143
react-redux: https://github.com/reduxjs/react-redux/blob/master/package.json#L52
react-apollo: https://github.com/apollographql/react-apollo/blob/master/packages/hoc/package.json#L45

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth the bundle size change here for a much smoother experience, it adds 1.2kb gzipped + minified - https://bundlephobia.com/result?p=hoist-non-react-statics@3.3.2

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's keep it for now.

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