├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── main.js └── test ├── browser.test.js ├── genericTest.js └── node.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": ["last 2 versions", "safari >= 7"] 8 | }, 9 | "modules": false 10 | } 11 | ] 12 | ], 13 | "env": { 14 | "test": { 15 | "presets": [ 16 | [ 17 | "@babel/preset-env", 18 | { 19 | "targets": { 20 | "node": "current" 21 | }, 22 | "modules": false 23 | } 24 | ] 25 | ] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Dependency directories 9 | node_modules/ 10 | 11 | # Optional npm cache directory 12 | .npm 13 | 14 | # Optional eslint cache 15 | .eslintcache 16 | 17 | # File system 18 | .DS_Store 19 | 20 | # Build directory 21 | dist/ 22 | 23 | # Jest 24 | coverage/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vidit-sh/redux-sentry-middleware/12b6fae99f506baa24b83511c4d9b582e19f123f/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | deploy: 5 | provider: npm 6 | email: viditisonline@gmail.com 7 | api_key: 8 | secure: Hz1tXXNvvBIUzpyYtKaAaCnucrv0Ea917C7hpzB7Ad3lgn00wZTKb6yDJS4KPd5zhEuTu93JvwtFkqtX6lFxKUxPfyahe5hkV8tHrrGyANJEjEnO53uYUoh4ei8ivDhW8xsJbQMN/QteCOJ3lLFr/QLbPEY+p0Vr762+0YXKYC2JOm8eDSDjaJQtJhNBPTvsU6TEC2mVfwAGcZCPNSQgI8p22q519RI5RtMhkK+wsvRCBvuuVMSln44+1UrDi+Grvk0pc+ejKGAgBJ3E/uOXgTeCZnUS2ol/eODO9ZxEAznlD0hI6242ZE705ojfGoWfP5Qh4XOBCzI92RyzGmdAYHh1X6KkPgMsR4YZIpnUdI0kgai0jFsiDQo4mXKbGFh8o0DDuHpk5c9dOUG+TzZx0byoelCWY8bdvumYQwxpruh2ZUKg86VpOymVnG4AnBsm6kbtA0NlHI07D1RWh/ApGyZd8BKp1YgCsE/dAMKeIKgQc0L5iLYgVuvetUaQplWZnUumxNZ4nfv2CuILLs9cR8JY7mSOq1MrcT1cQxsQYCzjZSy74ZjVIh+ZcARVw5kPHrYntHBGowE84+aZSQgkye27hCmaEYKrMj+4ryZiOjzWK5PuEPwwVO5H4uDFa8x2IY7eEuYwsi5lKWt1MMXu1qrBhOpblrOrUEKTetFzvlw= 9 | on: 10 | tags: true 11 | repo: vidit-sh/redux-sentry-middleware 12 | skip_cleanup: true 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.1.3] - 2019-10-15 6 | 7 | ### Fixed 8 | 9 | - `getTags` transformation fixed, was applying tags to global `scope` and not to locally created `event` scope 10 | 11 | ## [0.1.2] - 2019-10-15 12 | 13 | ### Added 14 | 15 | - New `CHANGELOG.md`. Starting this version, all changes will be documented. 16 | 17 | ## [0.1.1] - 2019-08-21 18 | 19 | ### Security 20 | 21 | - Updated `jest` to fix package vulnerabilities 22 | 23 | ## [0.1.0] - 2019-08-21 24 | 25 | ### Added 26 | 27 | - New transformation function `breadcrumbMessageFromAction`. Read docs [here](https://github.com/vidit-sh/redux-sentry-middleware#breadcrumbmessagefromaction-function). 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/vidit-sh/redux-sentry-middleware.svg?branch=master)](https://travis-ci.org/vidit-sh/redux-sentry-middleware) 2 | [![Latest Version](https://img.shields.io/npm/v/redux-sentry-middleware.svg)](https://www.npmjs.com/package/redux-sentry-middleware) 3 | [![Downloads per month](https://img.shields.io/npm/dm/redux-sentry-middleware.svg)](https://www.npmjs.com/package/redux-sentry-middleware) 4 | 5 | # Sentry Middleware for Redux 6 | 7 | Logs the type of each dispatched action to Sentry as "breadcrumbs" and attaches 8 | your last action and current Redux state as additional context. 9 | 10 | It's a rewrite of [raven-for-redux](https://github.com/captbaritone/raven-for-redux) but with new Sentry unified APIs. 11 | 12 | ## Installation 13 | 14 | npm install --save redux-sentry-middleware 15 | 16 | ## Usage 17 | 18 | ```JavaScript 19 | // store.js 20 | 21 | import * as Sentry from "@sentry/browser"; 22 | // For usage with node 23 | // import * as Sentry from "@sentry/node"; 24 | 25 | import { createStore, applyMiddleware } from "redux"; 26 | import createSentryMiddleware from "redux-sentry-middleware"; 27 | 28 | import { reducer } from "./my_reducer"; 29 | 30 | Sentry.init({ 31 | dsn: '' 32 | }); 33 | 34 | 35 | export default createStore( 36 | reducer, 37 | applyMiddleware( 38 | // Middlewares, like `redux-thunk` that intercept or emit actions should 39 | // precede `redux-sentry-middleware`. 40 | createSentryMiddleware(Sentry, { 41 | // Optionally pass some options here. 42 | }) 43 | ) 44 | ); 45 | ``` 46 | 47 | ## API: `createSentryMiddleware(Sentry, [options])` 48 | 49 | ### Arguments 50 | 51 | * `Sentry` _(Sentry Object)_: A configured and "installed" 52 | [Sentry] object. 53 | * [`options`] _(Object)_: See below for detailed documentation. 54 | 55 | ### Options 56 | 57 | While the default configuration should work for most use cases, middleware can be configured by providing an options object with any of the following 58 | optional keys. 59 | 60 | #### `breadcrumbDataFromAction` _(Function)_ 61 | 62 | Default: `action => undefined` 63 | 64 | Sentry allows you to attach additional context information to each breadcrumb 65 | in the form of a `data` object. `breadcrumbDataFromAction` allows you to specify 66 | a transform function which is passed the `action` object and returns a `data` 67 | object. Which will be logged to Sentry along with the breadcrumb. 68 | 69 | _Ideally_ we could log the entire content of each action. If we could, we 70 | could perfectly replay the user's entire session to see what went wrong. 71 | 72 | However, the default implementation of this function returns `undefined`, which means 73 | no data is attached. This is because there are __a few gotchas__: 74 | 75 | * The data object must be "flat". In other words, each value of the object must be a string. The values may not be arrays or other objects. 76 | * Sentry limits the total size of your error report. If you send too much data, 77 | the error will not be recorded. If you are going to attach data to your 78 | breadcrumbs, be sure you understand the way it will affect the total size 79 | of your report. 80 | 81 | Finally, be careful not to mutate your `action` within this function. 82 | 83 | See the Sentry [Breadcrumb documentation]. 84 | 85 | #### `actionTransformer` _(Function)_ 86 | 87 | Default: `action => action` 88 | 89 | In some cases your actions may be extremely large, or contain sensitive data. 90 | In those cases, you may want to transform your action before sending it to 91 | Sentry. This function allows you to do so. It is passed the last dispatched 92 | `action` object, and should return a serializable value. 93 | 94 | Be careful not to mutate your `action` within this function. 95 | 96 | If you have specified a [`beforeSend`] when you configured Sentry, note that 97 | `actionTransformer` will be applied _before_ your specified `beforeSend`. 98 | 99 | #### `stateTransformer` _(Function)_ 100 | 101 | Default: `state => state` 102 | 103 | In some cases your state may be extremely large, or contain sensitive data. 104 | In those cases, you may want to transform your state before sending it to 105 | Sentry. This function allows you to do so. It is passed the current state 106 | object, and should return a serializable value. 107 | 108 | Be careful not to mutate your `state` within this function. 109 | 110 | If you have specified a [` beforeSend`] when you configured Raven, note that 111 | `stateTransformer` will be applied _before_ your specified `beforeSend`. 112 | 113 | #### `breadcrumbCategory` _(String)_ 114 | 115 | Default: `"redux-action"` 116 | 117 | Each breadcrumb is assigned a category. By default all action breadcrumbs are 118 | given the category `"redux-action"`. If you would prefer a different category 119 | name, specify it here. 120 | 121 | #### `filterBreadcrumbActions` _(Function)_ 122 | 123 | Default: `action => true` 124 | 125 | If your app has certain actions that you do not want to send to Sentry, pass 126 | a filter function in this option. If the filter returns a truthy value, the 127 | action will be added as a breadcrumb, otherwise the action will be ignored. 128 | Note: even when the action has been filtered out, it may still be sent to 129 | Sentry as part of the extra data, if it was the last action before an error. 130 | 131 | #### `getUserContext` _(Optional Function)_ 132 | 133 | Signature: `state => userContext` 134 | 135 | Sentry allows you to associcate a [user context] with each error report. 136 | `getUserContext` allows you to define a mapping from your Redux `state` to 137 | the user context. When `getUserContext` is specified, the result of 138 | `getUserContext` will be used to derive the user context before sending an 139 | error report. Be careful not to mutate your `state` within this function. 140 | 141 | If you have specified a [`beforeSend`] when you configured Raven, note that 142 | `getUserContext` will be applied _before_ your specified `beforeSend`. 143 | When a `getUserContext` function is given, it will override any previously 144 | set user context. 145 | 146 | #### `getTags` _(Optional Function)_ 147 | 148 | Signature: `state => tags` 149 | 150 | Sentry allows you to associate [tags] with each report. 151 | `getTags` allows you to define a mapping from your Redux `state` to 152 | an object of tags (key → value). Be careful not to mutate your `state` 153 | within this function. 154 | 155 | #### `breadcrumbMessageFromAction` _(Function)_ 156 | 157 | Default: `action => action.type` 158 | 159 | `breadcrumbMessageFromAction` allows you to specify a transform function which is passed the `action` object and returns a `string` that will be used as the message of the breadcrumb. 160 | 161 | By default `breadcrumbMessageFromAction` returns `action.type`. 162 | 163 | Finally, be careful not to mutate your `action` within this function. 164 | 165 | See the Sentry [Breadcrumb documentation](https://docs.sentry.io/enriching-error-data/breadcrumbs/?platform=javascript). -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-sentry-middleware", 3 | "version": "0.2.2", 4 | "description": "Redux middleware for propagating Redux state/actions to use with new @sentry/browser and @sentry/node.", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "browser": "dist/index.umd.js", 8 | "scripts": { 9 | "build": "rollup -c", 10 | "dev": "rollup -c -w", 11 | "test": "jest", 12 | "pretest": "npm run build" 13 | }, 14 | "repository": "git+https://github.com/ViditIsOnline/redux-sentry-middleware.git", 15 | "keywords": [ 16 | "redux", 17 | "middleware", 18 | "sentry", 19 | "logging" 20 | ], 21 | "author": "Vidit Shah", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/ViditIsOnline/redux-sentry-middleware/issues" 25 | }, 26 | "homepage": "https://github.com/ViditIsOnline/redux-sentry-middleware#readme", 27 | "devDependencies": { 28 | "@babel/core": "^7.12.9", 29 | "@babel/preset-env": "^7.12.7", 30 | "@sentry/browser": "^5.27.6", 31 | "@sentry/node": "^5.27.6", 32 | "babel-core": "^7.0.0-bridge.0", 33 | "jest": "^24.9.0", 34 | "redux": "^4.0.5", 35 | "rollup": "^0.66.6", 36 | "rollup-plugin-babel": "^4.4.0" 37 | }, 38 | "files": [ 39 | "dist" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import pkg from "./package.json"; 3 | 4 | export default [ 5 | { 6 | input: "src/main.js", 7 | output: [ 8 | { 9 | file: pkg.browser, 10 | format: "umd" 11 | }, 12 | { file: pkg.main, format: "cjs" }, 13 | { file: pkg.module, format: "es" } 14 | ], 15 | plugins: [ 16 | babel({ 17 | exclude: ["node_modules/**"] 18 | }) 19 | ] 20 | } 21 | ]; 22 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | const identity = x => x; 2 | const getUndefined = () => {}; 3 | const filter = () => true; 4 | const getType = action => action.type; 5 | 6 | const createSentryMiddleware = (Sentry, options = {}) => { 7 | const { 8 | breadcrumbDataFromAction = getUndefined, 9 | breadcrumbMessageFromAction = getType, 10 | actionTransformer = identity, 11 | stateTransformer = identity, 12 | breadcrumbCategory = "redux-action", 13 | filterBreadcrumbActions = filter, 14 | getUserContext, 15 | getTags 16 | } = options; 17 | 18 | return store => { 19 | let lastAction = null; // assigning null is a workaround since sentry api normalizes the store data and converts undefined to '[undefined]' 20 | 21 | Sentry.configureScope(scope => { 22 | scope.addEventProcessor((event, hint) => { 23 | const state = store.getState(); 24 | 25 | event.extra = { 26 | ...event.extra, 27 | lastAction: actionTransformer(lastAction), 28 | state: stateTransformer(state) 29 | }; 30 | 31 | if (getUserContext) { 32 | event.user = { ...event.user, ...getUserContext(state) }; 33 | } 34 | if (getTags) { 35 | const tags = getTags(state); 36 | Object.keys(tags).forEach(key => { 37 | event.tags = { ...event.tags, [key]: tags[key] }; 38 | }); 39 | } 40 | return event; 41 | }); 42 | }); 43 | 44 | return next => action => { 45 | if (filterBreadcrumbActions(action)) { 46 | Sentry.addBreadcrumb({ 47 | category: breadcrumbCategory, 48 | message: breadcrumbMessageFromAction(action), 49 | level: "info", 50 | data: breadcrumbDataFromAction(action) 51 | }); 52 | } 53 | 54 | lastAction = action; 55 | return next(action); 56 | }; 57 | }; 58 | }; 59 | 60 | module.exports = createSentryMiddleware; 61 | -------------------------------------------------------------------------------- /test/browser.test.js: -------------------------------------------------------------------------------- 1 | const SentryBrowser = require("@sentry/browser"); 2 | const testSentryForRaven = require("./genericTest"); 3 | 4 | testSentryForRaven(SentryBrowser); 5 | -------------------------------------------------------------------------------- /test/genericTest.js: -------------------------------------------------------------------------------- 1 | const createSentryMiddleware = require("../src/main"); 2 | const { createStore, applyMiddleware } = require("redux"); 3 | 4 | /* 5 | * Initializing @sentry/browser and @sentry/node in the same environment seems 6 | * to cause problems, so we make this tests file genric and then define separate 7 | * test files for each environment. 8 | */ 9 | 10 | // Docs claim the default is 100 but it's really 30. To avoid ambiguity, we use our own: 11 | // Docs: https://docs.sentry.io/error-reporting/configuration/?platform=browser#max-breadcrumbs 12 | // Code: https://github.com/getsentry/sentry-javascript/blob/02b8ab64e7b3aaee0df34009340ab3139f027ab3/packages/hub/src/hub.ts#L52 13 | const MAX_BREADCRUMBS = 75; 14 | 15 | const sendEvent = jest.fn(async () => { 16 | // Must be thenable to prevent errors 17 | }); 18 | 19 | class MockTransport { 20 | constructor() { 21 | this.sendEvent = sendEvent; 22 | // This never gets called in practice 23 | this.close = jest.fn(async () => {}); 24 | } 25 | } 26 | 27 | function testSentryForRaven(Sentry) { 28 | Sentry.init({ 29 | dsn: "https://5d5bf17b1bed4afc9103b5a09634775e@sentry.io/146969", 30 | transport: MockTransport, 31 | maxBreadcrumbs: MAX_BREADCRUMBS 32 | }); 33 | 34 | function expectToThrow(cb) { 35 | expect(() => { 36 | try { 37 | cb(); 38 | } catch (e) { 39 | // Sentry does not seem to be able to capture global exceptions in Jest tests. 40 | // So we explicitly wrap this error in a Sentry captureException. 41 | Sentry.captureException(e); 42 | throw e; 43 | } 44 | }).toThrow(); 45 | } 46 | 47 | const reducer = (previousState = { value: 0 }, action) => { 48 | switch (action.type) { 49 | case "THROW": 50 | throw new Error("Reducer error"); 51 | case "INCREMENT": 52 | return { value: previousState.value + 1 }; 53 | case "DOUBLE": 54 | return { value: previousState.value * 2 }; 55 | default: 56 | return previousState; 57 | } 58 | }; 59 | 60 | const context = {}; 61 | 62 | beforeEach(() => { 63 | Sentry.configureScope(scope => { 64 | // Reset the context/extra/user/tags data. 65 | scope.clear(); 66 | // Remove any even processors added by the middleware. 67 | // I've reached out to the team to find out if there's a better way to do this. 68 | scope._eventProcessors = []; 69 | }); 70 | sendEvent.mockClear(); 71 | }); 72 | describe("in the default configuration", () => { 73 | beforeEach(() => { 74 | context.middleware = createSentryMiddleware(Sentry); 75 | context.store = createStore(reducer, applyMiddleware(context.middleware)); 76 | }); 77 | it("merges Redux info with existing 'extras'", async () => { 78 | Sentry.setExtras({ anotherValue: 10 }); 79 | Sentry.captureException(new Error("Crash!")); 80 | await Sentry.flush(); 81 | const { extra } = sendEvent.mock.calls[0][0]; 82 | expect(extra).toMatchObject({ 83 | state: { value: 0 }, 84 | lastAction: null, 85 | anotherValue: 10 86 | }); 87 | }); 88 | it("if explicitly passed extras contain a `state` property, the middleware version wins", async () => { 89 | Sentry.setExtras({ anotherValue: 10, state: "SOME OTHER STATE" }); 90 | Sentry.captureException(new Error("Crash!")); 91 | await Sentry.flush(); 92 | const { extra } = sendEvent.mock.calls[0][0]; 93 | expect(extra).toMatchObject({ 94 | state: { value: 0 }, 95 | lastAction: null, 96 | anotherValue: 10 97 | }); 98 | }); 99 | it("if explicitly passed extras contain a `lastAction` property, the middleware version wins", async () => { 100 | Sentry.setExtras({ 101 | anotherValue: 10, 102 | lastAction: "SOME OTHER LAST ACTION" 103 | }); 104 | expect(context.store.dispatch({ type: "INCREMENT" })); 105 | Sentry.captureException(new Error("Crash!")); 106 | await Sentry.flush(); 107 | const { extra } = sendEvent.mock.calls[0][0]; 108 | expect(extra).toMatchObject({ 109 | state: { value: 1 }, 110 | lastAction: { type: "INCREMENT" }, 111 | anotherValue: 10 112 | }); 113 | }); 114 | it("includes the initial state when crashing/messaging before any action has been dispatched", async () => { 115 | Sentry.captureMessage("report!"); 116 | 117 | await Sentry.flush(); 118 | expect(sendEvent).toHaveBeenCalledTimes(1); 119 | const { extra } = sendEvent.mock.calls[0][0]; 120 | expect(extra.lastAction).toBe(null); 121 | expect(extra.state).toEqual({ value: 0 }); 122 | }); 123 | it("returns the result of the next dispatch function", () => { 124 | expect(context.store.dispatch({ type: "INCREMENT" })).toEqual({ 125 | type: "INCREMENT" 126 | }); 127 | }); 128 | it("logs the last action that was dispatched", async () => { 129 | context.store.dispatch({ type: "INCREMENT" }); 130 | 131 | expectToThrow(() => { 132 | context.store.dispatch({ type: "THROW" }); 133 | }); 134 | await Sentry.flush(); 135 | 136 | expect(sendEvent).toHaveBeenCalledTimes(1); 137 | const { extra } = sendEvent.mock.calls[0][0]; 138 | expect(extra.lastAction).toEqual({ type: "THROW" }); 139 | }); 140 | it("logs the last state when crashing in the reducer", async () => { 141 | context.store.dispatch({ type: "INCREMENT" }); 142 | expectToThrow(() => { 143 | context.store.dispatch({ type: "THROW" }); 144 | }); 145 | 146 | await Sentry.flush(); 147 | expect(sendEvent).toHaveBeenCalledTimes(1); 148 | const { extra } = sendEvent.mock.calls[0][0]; 149 | expect(extra.state).toEqual({ value: 1 }); 150 | }); 151 | it("logs a breadcrumb for each action", async () => { 152 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 153 | expectToThrow(() => { 154 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 155 | }); 156 | 157 | await Sentry.flush(); 158 | expect(sendEvent).toHaveBeenCalledTimes(1); 159 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 160 | expect(breadcrumbs.length).toBe(2); 161 | expect(breadcrumbs[0]).toMatchObject({ 162 | category: "redux-action", 163 | message: "INCREMENT" 164 | }); 165 | expect(breadcrumbs[1]).toMatchObject({ 166 | category: "redux-action", 167 | message: "THROW" 168 | }); 169 | }); 170 | it("includes timestamps in the breadcrumbs", async () => { 171 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 172 | expectToThrow(() => { 173 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 174 | }); 175 | await Sentry.flush(); 176 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 177 | const firstBreadcrumb = breadcrumbs[1]; 178 | expect(firstBreadcrumb.timestamp).toBeLessThanOrEqual(+new Date() / 1000); 179 | }); 180 | it("trims breadcrumbs over MAX_BREADCRUMBS", async () => { 181 | let n = 150; 182 | expect(n > MAX_BREADCRUMBS).toBe(true); 183 | while (n--) { 184 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 185 | } 186 | expectToThrow(() => { 187 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 188 | }); 189 | await Sentry.flush(); 190 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 191 | expect(breadcrumbs.length).toBe(MAX_BREADCRUMBS); 192 | }); 193 | it("preserves order of native Sentry breadcrumbs & sentry-for-redux breadcrumbs", async () => { 194 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 195 | await new Promise(resolve => setTimeout(resolve, 100)); 196 | Sentry.addBreadcrumb({ message: "some message" }); 197 | await new Promise(resolve => setTimeout(resolve, 100)); 198 | expectToThrow(() => { 199 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 200 | }); 201 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 202 | expect(breadcrumbs.length).toBe(3); 203 | expect(breadcrumbs[0]).toMatchObject({ message: "INCREMENT" }); 204 | expect(breadcrumbs[1]).toMatchObject({ message: "some message" }); 205 | expect(breadcrumbs[2]).toMatchObject({ message: "THROW" }); 206 | }); 207 | it("includes the last state/action when crashing/reporting outside the reducer", () => { 208 | context.store.dispatch({ type: "INCREMENT" }); 209 | context.store.dispatch({ type: "INCREMENT" }); 210 | context.store.dispatch({ type: "DOUBLE" }); 211 | Sentry.captureMessage("report!"); 212 | 213 | expect(sendEvent).toHaveBeenCalledTimes(1); 214 | const { extra } = sendEvent.mock.calls[0][0]; 215 | expect(extra.lastAction).toEqual({ type: "DOUBLE" }); 216 | expect(extra.state).toEqual({ value: 4 }); 217 | }); 218 | it("preserves user context", () => { 219 | const userData = { userId: 1, username: "captbaritone" }; 220 | Sentry.setUser(userData); 221 | expectToThrow(() => { 222 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 223 | }); 224 | 225 | expect(sendEvent.mock.calls[0][0].user).toEqual(userData); 226 | }); 227 | }); 228 | describe("with all the options enabled", () => { 229 | beforeEach(() => { 230 | context.stateTransformer = jest.fn( 231 | state => `transformed state ${state.value}` 232 | ); 233 | context.actionTransformer = jest.fn( 234 | action => `transformed action ${action.type}` 235 | ); 236 | context.getUserContext = jest.fn(state => ({ 237 | name: `user context ${state.value}` 238 | })); 239 | context.getTags = jest.fn(state => ({ tagKey: `tags ${state.value}` })); 240 | context.breadcrumbDataFromAction = jest.fn(action => ({ 241 | extra: action.extra 242 | })); 243 | context.breadcrumbMessageFromAction = jest.fn( 244 | action => `transformed action ${action.type}` 245 | ); 246 | context.filterBreadcrumbActions = action => { 247 | return action.type !== "UNINTERESTING_ACTION"; 248 | }; 249 | 250 | context.store = createStore( 251 | reducer, 252 | applyMiddleware( 253 | createSentryMiddleware(Sentry, { 254 | stateTransformer: context.stateTransformer, 255 | actionTransformer: context.actionTransformer, 256 | breadcrumbDataFromAction: context.breadcrumbDataFromAction, 257 | breadcrumbMessageFromAction: context.breadcrumbMessageFromAction, 258 | filterBreadcrumbActions: context.filterBreadcrumbActions, 259 | getUserContext: context.getUserContext, 260 | getTags: context.getTags 261 | }) 262 | ) 263 | ); 264 | }); 265 | it("does not transform the state or action until an exception is encountered", () => { 266 | context.store.dispatch({ type: "INCREMENT" }); 267 | expect(context.stateTransformer).not.toHaveBeenCalled(); 268 | expect(context.actionTransformer).not.toHaveBeenCalled(); 269 | }); 270 | it("transforms the action if an error is encountered", () => { 271 | context.store.dispatch({ type: "INCREMENT" }); 272 | expectToThrow(() => { 273 | context.store.dispatch({ type: "THROW" }); 274 | }); 275 | 276 | expect(sendEvent).toHaveBeenCalledTimes(1); 277 | const { extra } = sendEvent.mock.calls[0][0]; 278 | expect(extra.lastAction).toEqual("transformed action THROW"); 279 | }); 280 | it("transforms the state if an error is encountered", () => { 281 | context.store.dispatch({ type: "INCREMENT" }); 282 | expectToThrow(() => { 283 | context.store.dispatch({ type: "THROW" }); 284 | }); 285 | 286 | expect(sendEvent).toHaveBeenCalledTimes(1); 287 | const { extra } = sendEvent.mock.calls[0][0]; 288 | expect(extra.state).toEqual("transformed state 1"); 289 | }); 290 | it("derives breadcrumb data from action", () => { 291 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 292 | expectToThrow(() => { 293 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 294 | }); 295 | 296 | expect(sendEvent).toHaveBeenCalledTimes(1); 297 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 298 | expect(breadcrumbs.length).toBe(2); 299 | expect(breadcrumbs[0].message).toBe("transformed action INCREMENT"); 300 | expect(breadcrumbs[0].data).toMatchObject({ extra: "FOO" }); 301 | expect(breadcrumbs[1].message).toBe("transformed action THROW"); 302 | expect(breadcrumbs[1].data).toMatchObject({ extra: "BAR" }); 303 | }); 304 | it("transforms the user context on data callback", () => { 305 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 306 | const userData = { userId: 1, username: "captbaritone" }; 307 | Sentry.setUser(userData); 308 | expectToThrow(() => { 309 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 310 | }); 311 | 312 | expect(sendEvent.mock.calls[0][0].user).toEqual({ 313 | name: "user context 1", 314 | userId: 1, 315 | username: "captbaritone" 316 | }); 317 | }); 318 | it("transforms the tags on data callback", () => { 319 | context.store.dispatch({ type: "INCREMENT", extra: "FOO" }); 320 | expectToThrow(() => { 321 | context.store.dispatch({ type: "THROW", extra: "BAR" }); 322 | }); 323 | expect(sendEvent).toHaveBeenCalledTimes(1); 324 | expect(sendEvent.mock.calls[0][0].tags).toEqual({ tagKey: "tags 1" }); 325 | }); 326 | }); 327 | describe("with filterBreadcrumbActions option enabled", () => { 328 | beforeEach(() => { 329 | context.filterBreadcrumbActions = action => { 330 | return action.type !== "UNINTERESTING_ACTION"; 331 | }; 332 | 333 | context.store = createStore( 334 | reducer, 335 | applyMiddleware( 336 | createSentryMiddleware(Sentry, { 337 | filterBreadcrumbActions: context.filterBreadcrumbActions 338 | }) 339 | ) 340 | ); 341 | }); 342 | it("filters actions for breadcrumbs", () => { 343 | context.store.dispatch({ type: "INCREMENT" }); 344 | context.store.dispatch({ type: "UNINTERESTING_ACTION" }); 345 | context.store.dispatch({ type: "UNINTERESTING_ACTION" }); 346 | Sentry.captureMessage("report!"); 347 | 348 | expect(sendEvent).toHaveBeenCalledTimes(1); 349 | const { breadcrumbs } = sendEvent.mock.calls[0][0]; 350 | expect(breadcrumbs.length).toBe(1); 351 | }); 352 | it("sends action with data.extra even if it was filtered", async () => { 353 | context.store.dispatch({ type: "UNINTERESTING_ACTION" }); 354 | Sentry.captureMessage("report!"); 355 | 356 | expect(sendEvent).toHaveBeenCalledTimes(1); 357 | const { extra } = sendEvent.mock.calls[0][0]; 358 | // Even though the action isn't added to breadcrumbs, it should be sent with extra data 359 | expect(extra.lastAction).toEqual({ type: "UNINTERESTING_ACTION" }); 360 | }); 361 | }); 362 | describe("Middleware is attached to the scope", () => { 363 | // This is important for server rendering use cases 364 | it("so middlewares in different scopes don't affect eachother", async () => { 365 | // The first request errors after four increments. 366 | Sentry.withScope(() => { 367 | const store = createStore( 368 | reducer, 369 | applyMiddleware(createSentryMiddleware(Sentry)) 370 | ); 371 | store.dispatch({ type: "INCREMENT" }); 372 | store.dispatch({ type: "INCREMENT" }); 373 | store.dispatch({ type: "INCREMENT" }); 374 | store.dispatch({ type: "INCREMENT" }); 375 | expectToThrow(() => { 376 | store.dispatch({ type: "THROW" }); 377 | }); 378 | }); 379 | // The second request errors before any increments. 380 | Sentry.withScope(() => { 381 | const store = createStore( 382 | reducer, 383 | applyMiddleware(createSentryMiddleware(Sentry)) 384 | ); 385 | expectToThrow(() => { 386 | store.dispatch({ type: "THROW" }); 387 | }); 388 | }); 389 | 390 | await Sentry.flush(); 391 | expect(sendEvent).toHaveBeenCalledTimes(2); 392 | expect(sendEvent.mock.calls[0][0].extra.state.value).toBe(4); 393 | expect(sendEvent.mock.calls[1][0].extra.state.value).toBe(0); 394 | }); 395 | it("so errors thrown in child scopes include Redux context", () => { 396 | createStore(reducer, applyMiddleware(createSentryMiddleware(Sentry))); 397 | Sentry.withScope(() => { 398 | Sentry.captureException(new Error("Whoo's")); 399 | }); 400 | 401 | expect(sendEvent).toHaveBeenCalledTimes(1); 402 | expect(sendEvent.mock.calls[0][0].extra.state.value).toBe(0); 403 | }); 404 | it("so errors thrown in a parent scope don't include Redux context", async () => { 405 | Sentry.withScope(() => { 406 | createStore(reducer, applyMiddleware(createSentryMiddleware(Sentry))); 407 | }); 408 | 409 | Sentry.captureException(new Error("Whoo's")); 410 | 411 | await Sentry.flush(); 412 | expect(sendEvent).toHaveBeenCalledTimes(1); 413 | expect(sendEvent.mock.calls[0][0].extra).toBe(undefined); 414 | }); 415 | 416 | // This test is included to document _current_ behavior, not nessesarily desired behavior. 417 | it("so (sadly) multiple middleware's created in the same scope will collide (the last one wins)", async () => { 418 | const storeOne = createStore( 419 | reducer, 420 | applyMiddleware(createSentryMiddleware(Sentry)) 421 | ); 422 | const storeTwo = createStore( 423 | reducer, 424 | applyMiddleware(createSentryMiddleware(Sentry)) 425 | ); 426 | 427 | storeOne.dispatch({ type: "INCREMENT" }); 428 | storeOne.dispatch({ type: "INCREMENT" }); 429 | storeOne.dispatch({ type: "INCREMENT" }); 430 | storeOne.dispatch({ type: "INCREMENT" }); 431 | 432 | storeTwo.dispatch({ type: "INCREMENT" }); 433 | 434 | Sentry.captureException(new Error("Whoo's")); 435 | 436 | await Sentry.flush(); 437 | expect(sendEvent).toHaveBeenCalledTimes(1); 438 | const reportedState = sendEvent.mock.calls[0][0].extra.state; 439 | expect(reportedState).toEqual(storeTwo.getState()); 440 | expect(reportedState).not.toEqual(storeOne.getState()); 441 | }); 442 | }); 443 | } 444 | 445 | module.exports = testSentryForRaven; 446 | -------------------------------------------------------------------------------- /test/node.test.js: -------------------------------------------------------------------------------- 1 | const SentryNode = require("@sentry/node"); 2 | const testSentryForRaven = require("./genericTest"); 3 | 4 | testSentryForRaven(SentryNode); 5 | --------------------------------------------------------------------------------