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: Redux Integration in @sentry/react #2717

Merged
merged 14 commits into from Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -17,6 +17,7 @@
- [tracing] fix: APM CDN bundle expose startTransaction (#2726)
- [browser] fix: Correctly remove all event listeners (#2725)
- [tracing] fix: Add manual `DOMStringList` typing (#2718)
- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717)

## 5.19.0

Expand Down
2 changes: 2 additions & 0 deletions packages/react/package.json
Expand Up @@ -17,6 +17,7 @@
},
"dependencies": {
"@sentry/browser": "5.19.2",
"@sentry/minimal": "5.19.2",
"@sentry/types": "5.19.2",
"@sentry/utils": "5.19.2",
"hoist-non-react-statics": "^3.3.2",
Expand All @@ -39,6 +40,7 @@
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-test-renderer": "^16.13.1",
"redux": "^4.0.5",
jennmueng marked this conversation as resolved.
Show resolved Hide resolved
"rimraf": "^2.6.3",
"tslint": "^5.16.0",
"tslint-react": "^5.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Expand Up @@ -25,5 +25,6 @@ export * from '@sentry/browser';

export { Profiler, withProfiler, useProfiler } from './profiler';
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
export { createReduxEnhancer } from './redux';

createReactEventProcessor();
83 changes: 83 additions & 0 deletions packages/react/src/redux.ts
@@ -0,0 +1,83 @@
// @flow
import { configureScope } from '@sentry/minimal';
import { Scope } from '@sentry/types';
import { Action, AnyAction, PreloadedState, Reducer, StoreEnhancer, StoreEnhancerStoreCreator } from 'redux';

export interface SentryEnhancerOptions {
/**
* Transforms the state before attaching it to an event.
* Use this to remove any private data before sending it to Sentry.
* Return null to not attach the state.
*/
stateTransformer(state: any | undefined): any | null;
/**
* Transforms the action before sending it as a breadcrumb.
* Use this to remove any private data before sending it to Sentry.
* Return null to not send the breadcrumb.
*/
actionTransformer(action: AnyAction): AnyAction | null;
/**
* Called on every state update, configure the Sentry Scope with the redux state.
*/
configureScopeWithState?(scope: Scope, state: any): void;
}

const ACTION_BREADCRUMB_CATEGORY = 'redux.action';
const ACTION_BREADCRUMB_TYPE = 'info';
const STATE_CONTEXT_KEY = 'redux.state';

const defaultOptions: SentryEnhancerOptions = {
actionTransformer: action => action,
// tslint:disable-next-line: no-unsafe-any
stateTransformer: state => state,
};

function createReduxEnhancer(enhancerOptions?: Partial<SentryEnhancerOptions>): StoreEnhancer {
const options = {
...defaultOptions,
...enhancerOptions,
};

return (next: StoreEnhancerStoreCreator): StoreEnhancerStoreCreator => <S = any, A extends Action = AnyAction>(
reducer: Reducer<S, A>,
initialState?: PreloadedState<S>,
) => {
const sentryReducer: Reducer<S, A> = (state, action): S => {
const newState = reducer(state, action);

configureScope(scope => {
/* Action breadcrumbs */
const transformedAction = options.actionTransformer(action);
// tslint:disable-next-line: strict-type-predicates
if (typeof transformedAction !== 'undefined' && transformedAction !== null) {
scope.addBreadcrumb({
category: ACTION_BREADCRUMB_CATEGORY,
data: transformedAction,
type: ACTION_BREADCRUMB_TYPE,
});
}

/* Set latest state to scope */
const transformedState = options.stateTransformer(newState);
if (typeof transformedState !== 'undefined' && transformedState !== null) {
// tslint:disable-next-line: no-unsafe-any
scope.setContext(STATE_CONTEXT_KEY, transformedState);
} else {
scope.setContext(STATE_CONTEXT_KEY, null);
}

/* Allow user to configure scope with latest state */
const { configureScopeWithState } = options;
if (typeof configureScopeWithState === 'function') {
configureScopeWithState(scope, newState);
}
});

return newState;
};

return next(sentryReducer, initialState);
};
}

export { createReduxEnhancer };
230 changes: 230 additions & 0 deletions packages/react/test/redux.test.ts
@@ -0,0 +1,230 @@
// tslint:disable-next-line: no-implicit-dependencies
import * as Sentry from '@sentry/minimal';
import { Scope } from '@sentry/types';
import * as Redux from 'redux';

import { createReduxEnhancer } from '../src/redux';

const mockAddBreadcrumb = jest.fn();
const mockSetContext = jest.fn();

jest.mock('@sentry/minimal', () => ({
configureScope: (callback: (scope: any) => Partial<Scope>) =>
callback({
addBreadcrumb: mockAddBreadcrumb,
setContext: mockSetContext,
}),
}));

afterEach(() => {
mockAddBreadcrumb.mockReset();
mockSetContext.mockReset();
});

describe('createReduxEnhancer', () => {
it('logs redux action as breadcrumb', () => {
const enhancer = createReduxEnhancer();

const initialState = {};

const store = Redux.createStore(() => initialState, enhancer);

const action = { type: 'TEST_ACTION' };
store.dispatch(action);

expect(mockAddBreadcrumb).toBeCalledWith({
category: 'redux.action',
jennmueng marked this conversation as resolved.
Show resolved Hide resolved
data: action,
type: 'info',
});
});

it('sets latest state on to scope', () => {
const enhancer = createReduxEnhancer();

const initialState = {
value: 'initial',
};
const ACTION_TYPE = 'UPDATE_VALUE';

const store = Redux.createStore((state: object = initialState, action: { type: string; newValue: any }) => {
if (action.type === ACTION_TYPE) {
return {
...state,
value: action.newValue,
};
}
return state;
}, enhancer);

const updateAction = { type: ACTION_TYPE, newValue: 'updated' };
store.dispatch(updateAction);

expect(mockSetContext).toBeCalledWith('redux.state', {
value: 'updated',
});
});

describe('transformers', () => {
it('transforms state', () => {
const enhancer = createReduxEnhancer({
stateTransformer: state => ({
...state,
superSecret: 'REDACTED',
}),
});

const initialState = {
superSecret: 'SECRET!',
value: 123,
};

Redux.createStore((state = initialState) => state, enhancer);

expect(mockSetContext).toBeCalledWith('redux.state', {
superSecret: 'REDACTED',
value: 123,
});
});

it('clears state if transformer returns null', () => {
const enhancer = createReduxEnhancer({
stateTransformer: () => null,
});

const initialState = {
superSecret: 'SECRET!',
value: 123,
};

Redux.createStore((state = initialState) => state, enhancer);

// Check that state is cleared
expect(mockSetContext).toBeCalledWith('redux.state', null);
});

it('transforms actions', () => {
const ACTION_TYPES = {
SAFE: 'SAFE_ACTION',
SECRET: 'SUPER_SECRET_ACTION',
};

const enhancer = createReduxEnhancer({
actionTransformer: action => {
if (action.type === ACTION_TYPES.SECRET) {
return {
...action,
secret: 'I love pizza',
};
}
return action;
},
});

const initialState = {};

const store = Redux.createStore(() => initialState, enhancer);

store.dispatch({
secret: 'The Nuclear Launch Code is: Pizza',
type: ACTION_TYPES.SECRET,
});

expect(mockAddBreadcrumb).toBeCalledWith({
category: 'redux.action',
data: {
secret: 'I love pizza',
type: ACTION_TYPES.SECRET,
},
type: 'info',
});

const safeAction = {
secret: 'Not really a secret am I',
type: ACTION_TYPES.SAFE,
};
store.dispatch(safeAction);

expect(mockAddBreadcrumb).toBeCalledWith({
category: 'redux.action',
data: safeAction,
type: 'info',
});

// first time is redux initialize
expect(mockAddBreadcrumb).toBeCalledTimes(3);
});

it("doesn't send action if transformer returns null", () => {
const enhancer = createReduxEnhancer({
actionTransformer: action => {
if (action.type === 'COCA_COLA_RECIPE') {
return null;
}
return action;
},
});

const initialState = {};

const store = Redux.createStore((state = initialState) => state, enhancer);

const safeAction = {
type: 'SAFE',
};
store.dispatch(safeAction);

const secretAction = {
cocaColaRecipe: {
everythingElse: '10ml',
sugar: '990ml',
},
type: 'COCA_COLA_RECIPE',
};
store.dispatch(secretAction);

// first time is redux initialize
expect(mockAddBreadcrumb).toBeCalledTimes(2);
expect(mockAddBreadcrumb).toBeCalledWith({
category: 'redux.action',
data: safeAction,
type: 'info',
});
});
});

it('configureScopeWithState is passed latest state', () => {
const configureScopeWithState = jest.fn();
const enhancer = createReduxEnhancer({
configureScopeWithState,
});

const initialState = {
value: 'outdated',
};

const UPDATE_VALUE = 'UPDATE_VALUE';

const store = Redux.createStore((state: object = initialState, action: { type: string; value: any }) => {
if (action.type === UPDATE_VALUE) {
return {
...state,
value: action.value,
};
}
return state;
}, enhancer);

store.dispatch({
type: UPDATE_VALUE,
value: 'latest',
});

let scopeRef;
Sentry.configureScope(scope => (scopeRef = scope));

expect(configureScopeWithState).toBeCalledWith(scopeRef, {
value: 'latest',
});
});
});