Skip to content

Commit

Permalink
feat: Redux Integration in @sentry/react (#2717)
Browse files Browse the repository at this point in the history
  • Loading branch information
jennmueng committed Jul 14, 2020
1 parent fee92a5 commit d409dc6
Show file tree
Hide file tree
Showing 6 changed files with 364 additions and 1 deletion.
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",
"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',
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',
});
});
});

0 comments on commit d409dc6

Please sign in to comment.