diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebecb330dfb..00ac4aa25416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/react/package.json b/packages/react/package.json index f630ab9788bc..2a5d6734d807 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", @@ -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", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3d8842ad43d6..84c7345fe66e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -25,5 +25,6 @@ export * from '@sentry/browser'; export { Profiler, withProfiler, useProfiler } from './profiler'; export { ErrorBoundary, withErrorBoundary } from './errorboundary'; +export { createReduxEnhancer } from './redux'; createReactEventProcessor(); diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts new file mode 100644 index 000000000000..8fa1547f84b6 --- /dev/null +++ b/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): StoreEnhancer { + const options = { + ...defaultOptions, + ...enhancerOptions, + }; + + return (next: StoreEnhancerStoreCreator): StoreEnhancerStoreCreator => ( + reducer: Reducer, + initialState?: PreloadedState, + ) => { + const sentryReducer: Reducer = (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 }; diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts new file mode 100644 index 000000000000..1fd4da3230c6 --- /dev/null +++ b/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) => + 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', + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 880fb24571e5..b638089c590c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1833,11 +1833,32 @@ after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" -agent-base@4, agent-base@5, agent-base@6, agent-base@^4.3.0, agent-base@~4.2.0: +agent-base@4, agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + +agent-base@5: version "5.1.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== +agent-base@6: + version "6.0.0" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.0.tgz#5d0101f19bbfaed39980b22ae866de153b93f09a" + integrity sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw== + dependencies: + debug "4" + +agent-base@~4.2.0: + version "4.2.1" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" + integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== + dependencies: + es6-promisify "^5.0.0" + agentkeepalive@^3.4.1: version "3.5.2" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.5.2.tgz#a113924dd3fa24a0bc3b78108c450c2abee00f67" @@ -4598,6 +4619,18 @@ es-to-primitive@^1.1.1, es-to-primitive@^1.2.0: is-date-object "^1.0.1" is-symbol "^1.0.2" +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= + dependencies: + es6-promise "^4.0.3" + escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -9673,6 +9706,14 @@ redent@^2.0.0: indent-string "^3.0.0" strip-indent "^2.0.0" +redux@^4.0.5: + version "4.0.5" + resolved "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + reflect-metadata@^0.1.12: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -10956,6 +10997,11 @@ supports-hyperlinks@^1.0.1: has-flag "^2.0.0" supports-color "^5.0.0" +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + symbol-tree@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"