Skip to content

Commit d409dc6

Browse files
authored
feat: Redux Integration in @sentry/react (#2717)
1 parent fee92a5 commit d409dc6

File tree

6 files changed

+364
-1
lines changed

6 files changed

+364
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- [tracing] fix: APM CDN bundle expose startTransaction (#2726)
1818
- [browser] fix: Correctly remove all event listeners (#2725)
1919
- [tracing] fix: Add manual `DOMStringList` typing (#2718)
20+
- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717)
2021

2122
## 5.19.0
2223

packages/react/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@sentry/browser": "5.19.2",
20+
"@sentry/minimal": "5.19.2",
2021
"@sentry/types": "5.19.2",
2122
"@sentry/utils": "5.19.2",
2223
"hoist-non-react-statics": "^3.3.2",
@@ -39,6 +40,7 @@
3940
"react": "^16.0.0",
4041
"react-dom": "^16.0.0",
4142
"react-test-renderer": "^16.13.1",
43+
"redux": "^4.0.5",
4244
"rimraf": "^2.6.3",
4345
"tslint": "^5.16.0",
4446
"tslint-react": "^5.0.0",

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ export * from '@sentry/browser';
2525

2626
export { Profiler, withProfiler, useProfiler } from './profiler';
2727
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
28+
export { createReduxEnhancer } from './redux';
2829

2930
createReactEventProcessor();

packages/react/src/redux.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @flow
2+
import { configureScope } from '@sentry/minimal';
3+
import { Scope } from '@sentry/types';
4+
import { Action, AnyAction, PreloadedState, Reducer, StoreEnhancer, StoreEnhancerStoreCreator } from 'redux';
5+
6+
export interface SentryEnhancerOptions {
7+
/**
8+
* Transforms the state before attaching it to an event.
9+
* Use this to remove any private data before sending it to Sentry.
10+
* Return null to not attach the state.
11+
*/
12+
stateTransformer(state: any | undefined): any | null;
13+
/**
14+
* Transforms the action before sending it as a breadcrumb.
15+
* Use this to remove any private data before sending it to Sentry.
16+
* Return null to not send the breadcrumb.
17+
*/
18+
actionTransformer(action: AnyAction): AnyAction | null;
19+
/**
20+
* Called on every state update, configure the Sentry Scope with the redux state.
21+
*/
22+
configureScopeWithState?(scope: Scope, state: any): void;
23+
}
24+
25+
const ACTION_BREADCRUMB_CATEGORY = 'redux.action';
26+
const ACTION_BREADCRUMB_TYPE = 'info';
27+
const STATE_CONTEXT_KEY = 'redux.state';
28+
29+
const defaultOptions: SentryEnhancerOptions = {
30+
actionTransformer: action => action,
31+
// tslint:disable-next-line: no-unsafe-any
32+
stateTransformer: state => state,
33+
};
34+
35+
function createReduxEnhancer(enhancerOptions?: Partial<SentryEnhancerOptions>): StoreEnhancer {
36+
const options = {
37+
...defaultOptions,
38+
...enhancerOptions,
39+
};
40+
41+
return (next: StoreEnhancerStoreCreator): StoreEnhancerStoreCreator => <S = any, A extends Action = AnyAction>(
42+
reducer: Reducer<S, A>,
43+
initialState?: PreloadedState<S>,
44+
) => {
45+
const sentryReducer: Reducer<S, A> = (state, action): S => {
46+
const newState = reducer(state, action);
47+
48+
configureScope(scope => {
49+
/* Action breadcrumbs */
50+
const transformedAction = options.actionTransformer(action);
51+
// tslint:disable-next-line: strict-type-predicates
52+
if (typeof transformedAction !== 'undefined' && transformedAction !== null) {
53+
scope.addBreadcrumb({
54+
category: ACTION_BREADCRUMB_CATEGORY,
55+
data: transformedAction,
56+
type: ACTION_BREADCRUMB_TYPE,
57+
});
58+
}
59+
60+
/* Set latest state to scope */
61+
const transformedState = options.stateTransformer(newState);
62+
if (typeof transformedState !== 'undefined' && transformedState !== null) {
63+
// tslint:disable-next-line: no-unsafe-any
64+
scope.setContext(STATE_CONTEXT_KEY, transformedState);
65+
} else {
66+
scope.setContext(STATE_CONTEXT_KEY, null);
67+
}
68+
69+
/* Allow user to configure scope with latest state */
70+
const { configureScopeWithState } = options;
71+
if (typeof configureScopeWithState === 'function') {
72+
configureScopeWithState(scope, newState);
73+
}
74+
});
75+
76+
return newState;
77+
};
78+
79+
return next(sentryReducer, initialState);
80+
};
81+
}
82+
83+
export { createReduxEnhancer };

packages/react/test/redux.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// tslint:disable-next-line: no-implicit-dependencies
2+
import * as Sentry from '@sentry/minimal';
3+
import { Scope } from '@sentry/types';
4+
import * as Redux from 'redux';
5+
6+
import { createReduxEnhancer } from '../src/redux';
7+
8+
const mockAddBreadcrumb = jest.fn();
9+
const mockSetContext = jest.fn();
10+
11+
jest.mock('@sentry/minimal', () => ({
12+
configureScope: (callback: (scope: any) => Partial<Scope>) =>
13+
callback({
14+
addBreadcrumb: mockAddBreadcrumb,
15+
setContext: mockSetContext,
16+
}),
17+
}));
18+
19+
afterEach(() => {
20+
mockAddBreadcrumb.mockReset();
21+
mockSetContext.mockReset();
22+
});
23+
24+
describe('createReduxEnhancer', () => {
25+
it('logs redux action as breadcrumb', () => {
26+
const enhancer = createReduxEnhancer();
27+
28+
const initialState = {};
29+
30+
const store = Redux.createStore(() => initialState, enhancer);
31+
32+
const action = { type: 'TEST_ACTION' };
33+
store.dispatch(action);
34+
35+
expect(mockAddBreadcrumb).toBeCalledWith({
36+
category: 'redux.action',
37+
data: action,
38+
type: 'info',
39+
});
40+
});
41+
42+
it('sets latest state on to scope', () => {
43+
const enhancer = createReduxEnhancer();
44+
45+
const initialState = {
46+
value: 'initial',
47+
};
48+
const ACTION_TYPE = 'UPDATE_VALUE';
49+
50+
const store = Redux.createStore((state: object = initialState, action: { type: string; newValue: any }) => {
51+
if (action.type === ACTION_TYPE) {
52+
return {
53+
...state,
54+
value: action.newValue,
55+
};
56+
}
57+
return state;
58+
}, enhancer);
59+
60+
const updateAction = { type: ACTION_TYPE, newValue: 'updated' };
61+
store.dispatch(updateAction);
62+
63+
expect(mockSetContext).toBeCalledWith('redux.state', {
64+
value: 'updated',
65+
});
66+
});
67+
68+
describe('transformers', () => {
69+
it('transforms state', () => {
70+
const enhancer = createReduxEnhancer({
71+
stateTransformer: state => ({
72+
...state,
73+
superSecret: 'REDACTED',
74+
}),
75+
});
76+
77+
const initialState = {
78+
superSecret: 'SECRET!',
79+
value: 123,
80+
};
81+
82+
Redux.createStore((state = initialState) => state, enhancer);
83+
84+
expect(mockSetContext).toBeCalledWith('redux.state', {
85+
superSecret: 'REDACTED',
86+
value: 123,
87+
});
88+
});
89+
90+
it('clears state if transformer returns null', () => {
91+
const enhancer = createReduxEnhancer({
92+
stateTransformer: () => null,
93+
});
94+
95+
const initialState = {
96+
superSecret: 'SECRET!',
97+
value: 123,
98+
};
99+
100+
Redux.createStore((state = initialState) => state, enhancer);
101+
102+
// Check that state is cleared
103+
expect(mockSetContext).toBeCalledWith('redux.state', null);
104+
});
105+
106+
it('transforms actions', () => {
107+
const ACTION_TYPES = {
108+
SAFE: 'SAFE_ACTION',
109+
SECRET: 'SUPER_SECRET_ACTION',
110+
};
111+
112+
const enhancer = createReduxEnhancer({
113+
actionTransformer: action => {
114+
if (action.type === ACTION_TYPES.SECRET) {
115+
return {
116+
...action,
117+
secret: 'I love pizza',
118+
};
119+
}
120+
return action;
121+
},
122+
});
123+
124+
const initialState = {};
125+
126+
const store = Redux.createStore(() => initialState, enhancer);
127+
128+
store.dispatch({
129+
secret: 'The Nuclear Launch Code is: Pizza',
130+
type: ACTION_TYPES.SECRET,
131+
});
132+
133+
expect(mockAddBreadcrumb).toBeCalledWith({
134+
category: 'redux.action',
135+
data: {
136+
secret: 'I love pizza',
137+
type: ACTION_TYPES.SECRET,
138+
},
139+
type: 'info',
140+
});
141+
142+
const safeAction = {
143+
secret: 'Not really a secret am I',
144+
type: ACTION_TYPES.SAFE,
145+
};
146+
store.dispatch(safeAction);
147+
148+
expect(mockAddBreadcrumb).toBeCalledWith({
149+
category: 'redux.action',
150+
data: safeAction,
151+
type: 'info',
152+
});
153+
154+
// first time is redux initialize
155+
expect(mockAddBreadcrumb).toBeCalledTimes(3);
156+
});
157+
158+
it("doesn't send action if transformer returns null", () => {
159+
const enhancer = createReduxEnhancer({
160+
actionTransformer: action => {
161+
if (action.type === 'COCA_COLA_RECIPE') {
162+
return null;
163+
}
164+
return action;
165+
},
166+
});
167+
168+
const initialState = {};
169+
170+
const store = Redux.createStore((state = initialState) => state, enhancer);
171+
172+
const safeAction = {
173+
type: 'SAFE',
174+
};
175+
store.dispatch(safeAction);
176+
177+
const secretAction = {
178+
cocaColaRecipe: {
179+
everythingElse: '10ml',
180+
sugar: '990ml',
181+
},
182+
type: 'COCA_COLA_RECIPE',
183+
};
184+
store.dispatch(secretAction);
185+
186+
// first time is redux initialize
187+
expect(mockAddBreadcrumb).toBeCalledTimes(2);
188+
expect(mockAddBreadcrumb).toBeCalledWith({
189+
category: 'redux.action',
190+
data: safeAction,
191+
type: 'info',
192+
});
193+
});
194+
});
195+
196+
it('configureScopeWithState is passed latest state', () => {
197+
const configureScopeWithState = jest.fn();
198+
const enhancer = createReduxEnhancer({
199+
configureScopeWithState,
200+
});
201+
202+
const initialState = {
203+
value: 'outdated',
204+
};
205+
206+
const UPDATE_VALUE = 'UPDATE_VALUE';
207+
208+
const store = Redux.createStore((state: object = initialState, action: { type: string; value: any }) => {
209+
if (action.type === UPDATE_VALUE) {
210+
return {
211+
...state,
212+
value: action.value,
213+
};
214+
}
215+
return state;
216+
}, enhancer);
217+
218+
store.dispatch({
219+
type: UPDATE_VALUE,
220+
value: 'latest',
221+
});
222+
223+
let scopeRef;
224+
Sentry.configureScope(scope => (scopeRef = scope));
225+
226+
expect(configureScopeWithState).toBeCalledWith(scopeRef, {
227+
value: 'latest',
228+
});
229+
});
230+
});

0 commit comments

Comments
 (0)