From 7268f9e784919f5071d394b2a5feba6528c93e8c Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Thu, 2 Jul 2020 16:00:46 +0700 Subject: [PATCH 01/14] feat(redux): Init redux store enhancer --- packages/react/package.json | 1 + packages/react/src/index.ts | 1 + packages/react/src/redux.tsx | 87 ++++++++++++++++++++++++++++++++++++ yarn.lock | 48 +++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/redux.tsx diff --git a/packages/react/package.json b/packages/react/package.json index f630ab9788bc..eeaf5b95b0bc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -39,6 +39,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.tsx b/packages/react/src/redux.tsx new file mode 100644 index 000000000000..20d545ff4e66 --- /dev/null +++ b/packages/react/src/redux.tsx @@ -0,0 +1,87 @@ +// @flow +import * as Sentry from '@sentry/browser'; +import * as Redux from 'redux'; + +export interface SentryMiddlewareOptions { + /** + * 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: object | undefined): object | 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: Redux.Action): Redux.Action | null; + /** + * Category of the breadcrumb sent by actions. Default is 'redux.action' + */ + actionBreadcrumbCategory?: string; + /** + * Type of the breadcrumb sent by actions. Default is 'info' + */ + actionBreadcrumbType?: string; + /** + * The extra key to pass the state to. Default is 'redux.state' + */ + stateExtraKey?: string; + /** + * Called on every state update, configure the Sentry Scope with the redux state. + */ + configureScopeWithState?(scope: Sentry.Scope, state: object | undefined): void; +} + +const defaultOptions = { + actionBreadcrumbCategory: 'redux.action', + actionBreadcrumbType: 'info', + actionTransformer: action => action, + stateExtraKey: 'redux.state', + stateTransformer: state => state, +}; + +function createReduxEnhancer(enhancerOptions: SentryMiddlewareOptions = defaultOptions): Redux.StoreEnhancer { + return next => (reducer, initialState) => { + const options = { + ...defaultOptions, + ...enhancerOptions, + }; + + const sentryReducer: Redux.Reducer = (state, action) => { + const newState = reducer(state, action); + + Sentry.configureScope(scope => { + /* Action breadcrumbs */ + const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; + if (typeof transformedAction !== 'undefined' && transformedAction !== null) { + scope.addBreadcrumb({ + category: options.actionBreadcrumbCategory, + data: transformedAction, + type: options.actionBreadcrumbType, + }); + } + + /* Set latest state to scope */ + const transformedState = options.stateTransformer ? options.stateTransformer(newState) : newState; + if (typeof transformedState !== 'undefined' && transformedState !== null) { + scope.setExtra(options.stateExtraKey, transformedState); + } else { + scope.setExtra(options.stateExtraKey, undefined); + } + + /* 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/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" From babd3e21cdaa30656faeb652d3b187e9fcf95d05 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Tue, 7 Jul 2020 16:38:24 +0700 Subject: [PATCH 02/14] ref: Filename should be .ts not .tsx as it doesn't use JSX --- packages/react/src/{redux.tsx => redux.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react/src/{redux.tsx => redux.ts} (100%) diff --git a/packages/react/src/redux.tsx b/packages/react/src/redux.ts similarity index 100% rename from packages/react/src/redux.tsx rename to packages/react/src/redux.ts From 4d7ecae74bb44afa90897f5525fe3daebe4dee09 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Tue, 7 Jul 2020 18:08:33 +0700 Subject: [PATCH 03/14] ref: Handle all ts errors --- packages/react/src/redux.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index 20d545ff4e66..9020a039ccca 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -8,32 +8,34 @@ export interface SentryMiddlewareOptions { * Use this to remove any private data before sending it to Sentry. * Return null to not attach the state. */ - stateTransformer?(state: object | undefined): object | null; + // tslint:disable-next-line: no-null-undefined-union + stateTransformer(state: object | undefined): object | null | undefined; /** * 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: Redux.Action): Redux.Action | null; + // tslint:disable-next-line: no-null-undefined-union + actionTransformer(action: Redux.Action): Redux.Action | null | undefined; /** * Category of the breadcrumb sent by actions. Default is 'redux.action' */ - actionBreadcrumbCategory?: string; + actionBreadcrumbCategory: string; /** * Type of the breadcrumb sent by actions. Default is 'info' */ - actionBreadcrumbType?: string; + actionBreadcrumbType: string; /** * The extra key to pass the state to. Default is 'redux.state' */ - stateExtraKey?: string; + stateExtraKey: string; /** * Called on every state update, configure the Sentry Scope with the redux state. */ configureScopeWithState?(scope: Sentry.Scope, state: object | undefined): void; } -const defaultOptions = { +const defaultOptions: SentryMiddlewareOptions = { actionBreadcrumbCategory: 'redux.action', actionBreadcrumbType: 'info', actionTransformer: action => action, @@ -41,7 +43,7 @@ const defaultOptions = { stateTransformer: state => state, }; -function createReduxEnhancer(enhancerOptions: SentryMiddlewareOptions = defaultOptions): Redux.StoreEnhancer { +function createReduxEnhancer(enhancerOptions?: Partial): Redux.StoreEnhancer { return next => (reducer, initialState) => { const options = { ...defaultOptions, @@ -49,14 +51,17 @@ function createReduxEnhancer(enhancerOptions: SentryMiddlewareOptions = defaultO }; const sentryReducer: Redux.Reducer = (state, action) => { - const newState = reducer(state, action); + // tslint:disable-next-line: no-unsafe-any + const newState: any = reducer(state, action); Sentry.configureScope(scope => { /* Action breadcrumbs */ + // tslint:disable-next-line: no-unsafe-any const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; if (typeof transformedAction !== 'undefined' && transformedAction !== null) { scope.addBreadcrumb({ category: options.actionBreadcrumbCategory, + // tslint:disable-next-line: no-unsafe-any data: transformedAction, type: options.actionBreadcrumbType, }); @@ -73,6 +78,7 @@ function createReduxEnhancer(enhancerOptions: SentryMiddlewareOptions = defaultO /* Allow user to configure scope with latest state */ const { configureScopeWithState } = options; if (typeof configureScopeWithState === 'function') { + // tslint:disable-next-line: no-unsafe-any configureScopeWithState(scope, newState); } }); From 8be887c44ed71753d055c35285140f223ae08cbf Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Tue, 7 Jul 2020 18:09:05 +0700 Subject: [PATCH 04/14] test: Add tests for redux enhancer --- packages/react/test/redux.test.ts | 229 ++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 packages/react/test/redux.test.ts diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts new file mode 100644 index 000000000000..2ed19f351e00 --- /dev/null +++ b/packages/react/test/redux.test.ts @@ -0,0 +1,229 @@ +import * as Sentry from '@sentry/browser'; +import { Scope } from '@sentry/types'; +import * as Redux from 'redux'; + +import { createReduxEnhancer } from '../src/redux'; + +const mockAddBreadcrumb = jest.fn(); +const mockSetExtra = jest.fn(); + +jest.mock('@sentry/browser', () => ({ + configureScope: (callback: (scope: any) => Partial) => + callback({ + addBreadcrumb: mockAddBreadcrumb, + setExtra: mockSetExtra, + }), +})); + +afterEach(() => { + mockAddBreadcrumb.mockReset(); + mockSetExtra.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(mockSetExtra).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(mockSetExtra).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(mockSetExtra).toBeCalledWith('redux.state', undefined); + }); + + 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', + }); + }); +}); From a87f5b53f9b4308e23761e9a63b9391f2f7a980b Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Tue, 7 Jul 2020 19:06:00 +0700 Subject: [PATCH 05/14] meta: Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebecb330dfb..43a3f3629b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717) + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott ## 5.19.2 From 2d80e4151d52f7e5217497c720de31c5a24c6cb7 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Wed, 8 Jul 2020 17:14:47 +0700 Subject: [PATCH 06/14] ref: Rename to SentryEnhancerOptions --- packages/react/src/redux.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index 9020a039ccca..f23b61dddc4b 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/browser'; import * as Redux from 'redux'; -export interface SentryMiddlewareOptions { +export interface SentryEnhancerOptions { /** * Transforms the state before attaching it to an event. * Use this to remove any private data before sending it to Sentry. @@ -35,7 +35,7 @@ export interface SentryMiddlewareOptions { configureScopeWithState?(scope: Sentry.Scope, state: object | undefined): void; } -const defaultOptions: SentryMiddlewareOptions = { +const defaultOptions: SentryEnhancerOptions = { actionBreadcrumbCategory: 'redux.action', actionBreadcrumbType: 'info', actionTransformer: action => action, @@ -43,7 +43,7 @@ const defaultOptions: SentryMiddlewareOptions = { stateTransformer: state => state, }; -function createReduxEnhancer(enhancerOptions?: Partial): Redux.StoreEnhancer { +function createReduxEnhancer(enhancerOptions?: Partial): Redux.StoreEnhancer { return next => (reducer, initialState) => { const options = { ...defaultOptions, From ffda389d8020ecc5baf06b5e59ff043e799396a0 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Wed, 8 Jul 2020 17:50:04 +0700 Subject: [PATCH 07/14] ref: TS typings --- packages/react/src/redux.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index f23b61dddc4b..bbbcf50f7b26 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -9,14 +9,14 @@ export interface SentryEnhancerOptions { * Return null to not attach the state. */ // tslint:disable-next-line: no-null-undefined-union - stateTransformer(state: object | undefined): object | null | undefined; + stateTransformer(state: S | undefined): S | null | undefined; /** * 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. */ // tslint:disable-next-line: no-null-undefined-union - actionTransformer(action: Redux.Action): Redux.Action | null | undefined; + actionTransformer(action: Redux.Action): Redux.Action | null | undefined; /** * Category of the breadcrumb sent by actions. Default is 'redux.action' */ @@ -32,7 +32,7 @@ export interface SentryEnhancerOptions { /** * Called on every state update, configure the Sentry Scope with the redux state. */ - configureScopeWithState?(scope: Sentry.Scope, state: object | undefined): void; + configureScopeWithState?(scope: Sentry.Scope, state: S | undefined): void; } const defaultOptions: SentryEnhancerOptions = { @@ -44,24 +44,27 @@ const defaultOptions: SentryEnhancerOptions = { }; function createReduxEnhancer(enhancerOptions?: Partial): Redux.StoreEnhancer { - return next => (reducer, initialState) => { - const options = { - ...defaultOptions, - ...enhancerOptions, - }; + const options = { + ...defaultOptions, + ...enhancerOptions, + }; - const sentryReducer: Redux.Reducer = (state, action) => { - // tslint:disable-next-line: no-unsafe-any - const newState: any = reducer(state, action); + return (next: Redux.StoreEnhancerStoreCreator): Redux.StoreEnhancerStoreCreator => < + S = any, + A extends Redux.Action = Redux.AnyAction + >( + reducer: Redux.Reducer, + initialState?: Redux.PreloadedState, + ) => { + const sentryReducer: Redux.Reducer = (state, action): S => { + const newState = reducer(state, action); Sentry.configureScope(scope => { /* Action breadcrumbs */ - // tslint:disable-next-line: no-unsafe-any const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; if (typeof transformedAction !== 'undefined' && transformedAction !== null) { scope.addBreadcrumb({ category: options.actionBreadcrumbCategory, - // tslint:disable-next-line: no-unsafe-any data: transformedAction, type: options.actionBreadcrumbType, }); @@ -78,7 +81,6 @@ function createReduxEnhancer(enhancerOptions?: Partial): /* Allow user to configure scope with latest state */ const { configureScopeWithState } = options; if (typeof configureScopeWithState === 'function') { - // tslint:disable-next-line: no-unsafe-any configureScopeWithState(scope, newState); } }); From 1dc1b2748fcfa0dafa3a42fad39308c7afe18a44 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Thu, 9 Jul 2020 14:42:56 +0700 Subject: [PATCH 08/14] ref: Make redux >=1.0.0 an optional dependency --- packages/react/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/package.json b/packages/react/package.json index eeaf5b95b0bc..96fe043d00a5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -26,6 +26,9 @@ "react": "15.x || 16.x", "react-dom": "15.x || 16.x" }, + "optionalDependencies": { + "redux": ">=1.0.0" + }, "devDependencies": { "@testing-library/react": "^10.0.6", "@testing-library/react-hooks": "^3.3.0", @@ -39,7 +42,6 @@ "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", From df10359e7d43a942b1b879ba9ecf3a7d78812da4 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Thu, 9 Jul 2020 15:14:02 +0700 Subject: [PATCH 09/14] ref: Typescript type fixes for stateTransformer and actionTransformer --- packages/react/src/redux.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index bbbcf50f7b26..e49920305d48 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -8,15 +8,13 @@ export interface SentryEnhancerOptions { * Use this to remove any private data before sending it to Sentry. * Return null to not attach the state. */ - // tslint:disable-next-line: no-null-undefined-union - stateTransformer(state: S | undefined): S | null | undefined; + stateTransformer(state: any | undefined): any | null | void; /** * 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. */ - // tslint:disable-next-line: no-null-undefined-union - actionTransformer(action: Redux.Action): Redux.Action | null | undefined; + actionTransformer(action: Redux.AnyAction): Redux.AnyAction | null | void; /** * Category of the breadcrumb sent by actions. Default is 'redux.action' */ @@ -32,7 +30,7 @@ export interface SentryEnhancerOptions { /** * Called on every state update, configure the Sentry Scope with the redux state. */ - configureScopeWithState?(scope: Sentry.Scope, state: S | undefined): void; + configureScopeWithState?(scope: Sentry.Scope, state: any): void; } const defaultOptions: SentryEnhancerOptions = { @@ -40,6 +38,7 @@ const defaultOptions: SentryEnhancerOptions = { actionBreadcrumbType: 'info', actionTransformer: action => action, stateExtraKey: 'redux.state', + // tslint:disable-next-line: no-unsafe-any stateTransformer: state => state, }; From 92ad547183a86ff120f8cb2e66d276a738bb8502 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Fri, 10 Jul 2020 20:04:05 +0700 Subject: [PATCH 10/14] ref: Should not allow transformers to not return, but check in case --- packages/react/src/redux.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index e49920305d48..ee77e8f42a8a 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -8,13 +8,13 @@ export interface SentryEnhancerOptions { * 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 | void; + 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: Redux.AnyAction): Redux.AnyAction | null | void; + actionTransformer(action: Redux.AnyAction): Redux.AnyAction | null; /** * Category of the breadcrumb sent by actions. Default is 'redux.action' */ @@ -61,6 +61,7 @@ function createReduxEnhancer(enhancerOptions?: Partial): Sentry.configureScope(scope => { /* Action breadcrumbs */ const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; + // tslint:disable-next-line: strict-type-predicates if (typeof transformedAction !== 'undefined' && transformedAction !== null) { scope.addBreadcrumb({ category: options.actionBreadcrumbCategory, From bb9b383e4d0a8d27569aaaa0b2f0966d5f80dab7 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Fri, 10 Jul 2020 20:07:53 +0700 Subject: [PATCH 11/14] meta: Changelog line fix --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a3f3629b8a..00ac4aa25416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,6 @@ ## Unreleased -- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717) - - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott ## 5.19.2 @@ -19,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 From f2f4d317ba808b52b036fc8dc3c48a57a625d73f Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Fri, 10 Jul 2020 20:25:51 +0700 Subject: [PATCH 12/14] ref: Redux as devDependency and use @sentry/minimal instead of browser --- packages/react/package.json | 5 ++--- packages/react/src/redux.ts | 24 +++++++++++------------- packages/react/test/redux.test.ts | 5 +++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 96fe043d00a5..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", @@ -26,9 +27,6 @@ "react": "15.x || 16.x", "react-dom": "15.x || 16.x" }, - "optionalDependencies": { - "redux": ">=1.0.0" - }, "devDependencies": { "@testing-library/react": "^10.0.6", "@testing-library/react-hooks": "^3.3.0", @@ -42,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/redux.ts b/packages/react/src/redux.ts index ee77e8f42a8a..0039209e41c1 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,6 +1,7 @@ // @flow -import * as Sentry from '@sentry/browser'; -import * as Redux from 'redux'; +import { configureScope } from '@sentry/minimal'; +import { Scope } from '@sentry/types'; +import { Action, AnyAction, PreloadedState, Reducer, StoreEnhancer, StoreEnhancerStoreCreator } from 'redux'; export interface SentryEnhancerOptions { /** @@ -14,7 +15,7 @@ export interface SentryEnhancerOptions { * Use this to remove any private data before sending it to Sentry. * Return null to not send the breadcrumb. */ - actionTransformer(action: Redux.AnyAction): Redux.AnyAction | null; + actionTransformer(action: AnyAction): AnyAction | null; /** * Category of the breadcrumb sent by actions. Default is 'redux.action' */ @@ -30,7 +31,7 @@ export interface SentryEnhancerOptions { /** * Called on every state update, configure the Sentry Scope with the redux state. */ - configureScopeWithState?(scope: Sentry.Scope, state: any): void; + configureScopeWithState?(scope: Scope, state: any): void; } const defaultOptions: SentryEnhancerOptions = { @@ -42,23 +43,20 @@ const defaultOptions: SentryEnhancerOptions = { stateTransformer: state => state, }; -function createReduxEnhancer(enhancerOptions?: Partial): Redux.StoreEnhancer { +function createReduxEnhancer(enhancerOptions?: Partial): StoreEnhancer { const options = { ...defaultOptions, ...enhancerOptions, }; - return (next: Redux.StoreEnhancerStoreCreator): Redux.StoreEnhancerStoreCreator => < - S = any, - A extends Redux.Action = Redux.AnyAction - >( - reducer: Redux.Reducer, - initialState?: Redux.PreloadedState, + return (next: StoreEnhancerStoreCreator): StoreEnhancerStoreCreator => ( + reducer: Reducer, + initialState?: PreloadedState, ) => { - const sentryReducer: Redux.Reducer = (state, action): S => { + const sentryReducer: Reducer = (state, action): S => { const newState = reducer(state, action); - Sentry.configureScope(scope => { + configureScope(scope => { /* Action breadcrumbs */ const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; // tslint:disable-next-line: strict-type-predicates diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts index 2ed19f351e00..4e734a316753 100644 --- a/packages/react/test/redux.test.ts +++ b/packages/react/test/redux.test.ts @@ -1,4 +1,5 @@ -import * as Sentry from '@sentry/browser'; +// tslint:disable-next-line: no-implicit-dependencies +import * as Sentry from '@sentry/minimal'; import { Scope } from '@sentry/types'; import * as Redux from 'redux'; @@ -7,7 +8,7 @@ import { createReduxEnhancer } from '../src/redux'; const mockAddBreadcrumb = jest.fn(); const mockSetExtra = jest.fn(); -jest.mock('@sentry/browser', () => ({ +jest.mock('@sentry/minimal', () => ({ configureScope: (callback: (scope: any) => Partial) => callback({ addBreadcrumb: mockAddBreadcrumb, From c7f28587448c8daec7c81ca46865c08dd769049d Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Mon, 13 Jul 2020 16:53:20 +0700 Subject: [PATCH 13/14] ref: Use context instead of extra for redux state. --- packages/react/src/redux.ts | 11 ++++++----- packages/react/test/redux.test.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index 0039209e41c1..30cdc3c2db1a 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -25,9 +25,9 @@ export interface SentryEnhancerOptions { */ actionBreadcrumbType: string; /** - * The extra key to pass the state to. Default is 'redux.state' + * The context key to pass the state to. Default is 'redux.state' */ - stateExtraKey: string; + stateContextKey: string; /** * Called on every state update, configure the Sentry Scope with the redux state. */ @@ -38,7 +38,7 @@ const defaultOptions: SentryEnhancerOptions = { actionBreadcrumbCategory: 'redux.action', actionBreadcrumbType: 'info', actionTransformer: action => action, - stateExtraKey: 'redux.state', + stateContextKey: 'redux.state', // tslint:disable-next-line: no-unsafe-any stateTransformer: state => state, }; @@ -71,9 +71,10 @@ function createReduxEnhancer(enhancerOptions?: Partial): /* Set latest state to scope */ const transformedState = options.stateTransformer ? options.stateTransformer(newState) : newState; if (typeof transformedState !== 'undefined' && transformedState !== null) { - scope.setExtra(options.stateExtraKey, transformedState); + // tslint:disable-next-line: no-unsafe-any + scope.setContext(options.stateContextKey, transformedState); } else { - scope.setExtra(options.stateExtraKey, undefined); + scope.setContext(options.stateContextKey, null); } /* Allow user to configure scope with latest state */ diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts index 4e734a316753..1fd4da3230c6 100644 --- a/packages/react/test/redux.test.ts +++ b/packages/react/test/redux.test.ts @@ -6,19 +6,19 @@ import * as Redux from 'redux'; import { createReduxEnhancer } from '../src/redux'; const mockAddBreadcrumb = jest.fn(); -const mockSetExtra = jest.fn(); +const mockSetContext = jest.fn(); jest.mock('@sentry/minimal', () => ({ configureScope: (callback: (scope: any) => Partial) => callback({ addBreadcrumb: mockAddBreadcrumb, - setExtra: mockSetExtra, + setContext: mockSetContext, }), })); afterEach(() => { mockAddBreadcrumb.mockReset(); - mockSetExtra.mockReset(); + mockSetContext.mockReset(); }); describe('createReduxEnhancer', () => { @@ -60,7 +60,7 @@ describe('createReduxEnhancer', () => { const updateAction = { type: ACTION_TYPE, newValue: 'updated' }; store.dispatch(updateAction); - expect(mockSetExtra).toBeCalledWith('redux.state', { + expect(mockSetContext).toBeCalledWith('redux.state', { value: 'updated', }); }); @@ -81,7 +81,7 @@ describe('createReduxEnhancer', () => { Redux.createStore((state = initialState) => state, enhancer); - expect(mockSetExtra).toBeCalledWith('redux.state', { + expect(mockSetContext).toBeCalledWith('redux.state', { superSecret: 'REDACTED', value: 123, }); @@ -100,7 +100,7 @@ describe('createReduxEnhancer', () => { Redux.createStore((state = initialState) => state, enhancer); // Check that state is cleared - expect(mockSetExtra).toBeCalledWith('redux.state', undefined); + expect(mockSetContext).toBeCalledWith('redux.state', null); }); it('transforms actions', () => { From 7c75f3b2f2b6ecd8135378b316c1396b61b2fa30 Mon Sep 17 00:00:00 2001 From: Jennarong Muengtaweepongsa Date: Tue, 14 Jul 2020 14:44:11 +0700 Subject: [PATCH 14/14] ref: Remove configurable state and action keys --- packages/react/src/redux.ts | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index 30cdc3c2db1a..8fa1547f84b6 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -16,29 +16,18 @@ export interface SentryEnhancerOptions { * Return null to not send the breadcrumb. */ actionTransformer(action: AnyAction): AnyAction | null; - /** - * Category of the breadcrumb sent by actions. Default is 'redux.action' - */ - actionBreadcrumbCategory: string; - /** - * Type of the breadcrumb sent by actions. Default is 'info' - */ - actionBreadcrumbType: string; - /** - * The context key to pass the state to. Default is 'redux.state' - */ - stateContextKey: string; /** * 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 = { - actionBreadcrumbCategory: 'redux.action', - actionBreadcrumbType: 'info', actionTransformer: action => action, - stateContextKey: 'redux.state', // tslint:disable-next-line: no-unsafe-any stateTransformer: state => state, }; @@ -58,23 +47,23 @@ function createReduxEnhancer(enhancerOptions?: Partial): configureScope(scope => { /* Action breadcrumbs */ - const transformedAction = options.actionTransformer ? options.actionTransformer(action) : action; + const transformedAction = options.actionTransformer(action); // tslint:disable-next-line: strict-type-predicates if (typeof transformedAction !== 'undefined' && transformedAction !== null) { scope.addBreadcrumb({ - category: options.actionBreadcrumbCategory, + category: ACTION_BREADCRUMB_CATEGORY, data: transformedAction, - type: options.actionBreadcrumbType, + type: ACTION_BREADCRUMB_TYPE, }); } /* Set latest state to scope */ - const transformedState = options.stateTransformer ? options.stateTransformer(newState) : newState; + const transformedState = options.stateTransformer(newState); if (typeof transformedState !== 'undefined' && transformedState !== null) { // tslint:disable-next-line: no-unsafe-any - scope.setContext(options.stateContextKey, transformedState); + scope.setContext(STATE_CONTEXT_KEY, transformedState); } else { - scope.setContext(options.stateContextKey, null); + scope.setContext(STATE_CONTEXT_KEY, null); } /* Allow user to configure scope with latest state */