diff --git a/packages/react/src/redux.ts b/packages/react/src/redux.ts index 5bac99dbe511..cb90424ba4ad 100644 --- a/packages/react/src/redux.ts +++ b/packages/react/src/redux.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { configureScope, getCurrentHub } from '@sentry/browser'; +import { addGlobalEventProcessor, configureScope, getCurrentHub } from '@sentry/browser'; import type { Scope } from '@sentry/types'; import { addNonEnumerableProperty } from '@sentry/utils'; @@ -49,6 +49,12 @@ type StoreEnhancerStoreCreator, StateExt = never> ) => Store, A, StateExt, Ext> & Ext; export interface SentryEnhancerOptions { + /** + * Redux state in attachments or not. + * @default true + */ + attachReduxState?: boolean; + /** * Transforms the state before attaching it to an event. * Use this to remove any private data before sending it to Sentry. @@ -71,6 +77,7 @@ const ACTION_BREADCRUMB_CATEGORY = 'redux.action'; const ACTION_BREADCRUMB_TYPE = 'info'; const defaultOptions: SentryEnhancerOptions = { + attachReduxState: true, actionTransformer: action => action, stateTransformer: state => state || null, }; @@ -89,6 +96,23 @@ function createReduxEnhancer(enhancerOptions?: Partial): return (next: StoreEnhancerStoreCreator): StoreEnhancerStoreCreator => (reducer: Reducer, initialState?: PreloadedState) => { + options.attachReduxState && + addGlobalEventProcessor((event, hint) => { + try { + // @ts-expect-error try catch to reduce bundle size + if (event.type === undefined && event.contexts.state.state.type === 'redux') { + hint.attachments = [ + ...(hint.attachments || []), + // @ts-expect-error try catch to reduce bundle size + { filename: 'redux_state.json', data: JSON.stringify(event.contexts.state.state.value) }, + ]; + } + } catch (_) { + // empty + } + return event; + }); + const sentryReducer: Reducer = (state, action): S => { const newState = reducer(state, action); diff --git a/packages/react/test/redux.test.ts b/packages/react/test/redux.test.ts index bf9fc31853c4..f8260a1dc278 100644 --- a/packages/react/test/redux.test.ts +++ b/packages/react/test/redux.test.ts @@ -14,11 +14,15 @@ jest.mock('@sentry/browser', () => ({ addBreadcrumb: mockAddBreadcrumb, setContext: mockSetContext, }), + addGlobalEventProcessor: jest.fn(), })); +const mockAddGlobalEventProcessor = Sentry.addGlobalEventProcessor as jest.Mock; + afterEach(() => { mockAddBreadcrumb.mockReset(); mockSetContext.mockReset(); + mockAddGlobalEventProcessor.mockReset(); }); describe('createReduxEnhancer', () => { @@ -243,4 +247,170 @@ describe('createReduxEnhancer', () => { value: 'latest', }); }); + + describe('Redux State Attachments', () => { + it('attaches Redux state to Sentry scope', () => { + const enhancer = createReduxEnhancer(); + + const initialState = { + value: 'initial', + }; + + Redux.createStore((state = initialState) => state, enhancer); + + expect(mockAddGlobalEventProcessor).toHaveBeenCalledTimes(1); + + const callbackFunction = mockAddGlobalEventProcessor.mock.calls[0][0]; + + const mockEvent = { + contexts: { + state: { + state: { + type: 'redux', + value: 'UPDATED_VALUE', + }, + }, + }, + }; + + const mockHint = { + attachments: [], + }; + + const result = callbackFunction(mockEvent, mockHint); + + expect(result).toEqual({ + ...mockEvent, + contexts: { + state: { + state: { + type: 'redux', + value: 'UPDATED_VALUE', + }, + }, + }, + }); + + expect(mockHint.attachments).toHaveLength(1); + expect(mockHint.attachments[0]).toEqual({ + filename: 'redux_state.json', + data: JSON.stringify('UPDATED_VALUE'), + }); + }); + + it('does not attach when attachReduxState is false', () => { + const enhancer = createReduxEnhancer({ attachReduxState: false }); + + const initialState = { + value: 'initial', + }; + + Redux.createStore((state = initialState) => state, enhancer); + + expect(mockAddGlobalEventProcessor).toHaveBeenCalledTimes(0); + }); + + it('does not attach when state.type is not redux', () => { + const enhancer = createReduxEnhancer(); + + const initialState = { + value: 'initial', + }; + + Redux.createStore((state = initialState) => state, enhancer); + + expect(mockAddGlobalEventProcessor).toHaveBeenCalledTimes(1); + + const callbackFunction = mockAddGlobalEventProcessor.mock.calls[0][0]; + + const mockEvent = { + contexts: { + state: { + state: { + type: 'not_redux', + value: 'UPDATED_VALUE', + }, + }, + }, + }; + + const mockHint = { + attachments: [], + }; + + const result = callbackFunction(mockEvent, mockHint); + + expect(result).toEqual(mockEvent); + + expect(mockHint.attachments).toHaveLength(0); + }); + + it('does not attach when state is undefined', () => { + const enhancer = createReduxEnhancer(); + + const initialState = { + value: 'initial', + }; + + Redux.createStore((state = initialState) => state, enhancer); + + expect(mockAddGlobalEventProcessor).toHaveBeenCalledTimes(1); + + const callbackFunction = mockAddGlobalEventProcessor.mock.calls[0][0]; + + const mockEvent = { + contexts: { + state: { + state: undefined, + }, + }, + }; + + const mockHint = { + attachments: [], + }; + + const result = callbackFunction(mockEvent, mockHint); + + expect(result).toEqual(mockEvent); + + expect(mockHint.attachments).toHaveLength(0); + }); + + it('does not attach when event type is not undefined', () => { + const enhancer = createReduxEnhancer(); + + const initialState = { + value: 'initial', + }; + + Redux.createStore((state = initialState) => state, enhancer); + + expect(mockAddGlobalEventProcessor).toHaveBeenCalledTimes(1); + + const callbackFunction = mockAddGlobalEventProcessor.mock.calls[0][0]; + + const mockEvent = { + type: 'not_redux', + contexts: { + state: { + state: { + type: 'redux', + value: 'UPDATED_VALUE', + }, + }, + }, + }; + + const mockHint = { + attachments: [], + }; + + const result = callbackFunction(mockEvent, mockHint); + + expect(result).toEqual(mockEvent); + + expect(mockHint.attachments).toHaveLength(0); + }); + }); }); diff --git a/packages/types/src/context.ts b/packages/types/src/context.ts index 110267284fc0..d52b674e2cbe 100644 --- a/packages/types/src/context.ts +++ b/packages/types/src/context.ts @@ -10,6 +10,14 @@ export interface Contexts extends Record { response?: ResponseContext; trace?: TraceContext; cloud_resource?: CloudResourceContext; + state?: StateContext; +} + +export interface StateContext extends Record { + state: { + type: string; + value: Record; + }; } export interface AppContext extends Record {