Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Redux Integration in @sentry/react (#2717)
- Loading branch information
Showing
6 changed files
with
364 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.