From f9f1c723cdc0fc21fbd892e5bd80222bb1943b41 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Wed, 27 Feb 2019 16:50:51 -0500 Subject: [PATCH] Add Logging Service and event fixes. - Original NewRelicService lifted from edx-portal. - Fixed bug with snake case of event data. - Added error handling for logEvent. - Added tests for logEvent. ARCH-430 --- .eslintrc | 3 + config/webpack.dev.config.js | 11 +- config/webpack.prod.config.js | 8 ++ package-lock.json | 179 ++++++++++++++++++++++------ package.json | 2 + src/analytics/analytics.js | 11 +- src/analytics/analytics.test.js | 57 +++++++++ src/services/LoggingService.js | 32 +++++ src/services/LoggingService.test.js | 66 ++++++++++ 9 files changed, 322 insertions(+), 47 deletions(-) create mode 100644 src/analytics/analytics.test.js create mode 100644 src/services/LoggingService.js create mode 100644 src/services/LoggingService.test.js diff --git a/.eslintrc b/.eslintrc index 9e1d0b7..a0125d9 100755 --- a/.eslintrc +++ b/.eslintrc @@ -20,5 +20,8 @@ }, "env": { "jest": true + }, + "globals": { + "newrelic": false } } diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js index 9bd0370..a986226 100755 --- a/config/webpack.dev.config.js +++ b/config/webpack.dev.config.js @@ -1,5 +1,6 @@ // This is the dev Webpack config. All settings here should prefer a fast build // time at the expense of creating larger, unoptimized bundles. + const Merge = require('webpack-merge'); const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); @@ -10,12 +11,12 @@ const commonConfig = require('./webpack.common.config.js'); module.exports = Merge.smart(commonConfig, { mode: 'development', devtool: 'eval-source-map', - entry: [ + entry: { // enable react's custom hot dev client so we get errors reported in the browser - require.resolve('react-dev-utils/webpackHotDevClient'), - path.resolve(__dirname, '../src/analytics/segment.js'), - path.resolve(__dirname, '../src/index.jsx'), - ], + hot: require.resolve('react-dev-utils/webpackHotDevClient'), + segment: path.resolve(__dirname, '../src/analytics/segment.js'), + app: path.resolve(__dirname, '../src/index.jsx'), + }, module: { // Specify file-by-file rules to Webpack. Some file-types need a particular kind of loader. rules: [ diff --git a/config/webpack.prod.config.js b/config/webpack.prod.config.js index 08d02c5..59cf57b 100755 --- a/config/webpack.prod.config.js +++ b/config/webpack.prod.config.js @@ -5,6 +5,7 @@ const commonConfig = require('./webpack.common.config.js'); const path = require('path'); const webpack = require('webpack'); const CleanWebpackPlugin = require('clean-webpack-plugin'); +const HtmlWebpackNewRelicPlugin = require('html-webpack-new-relic-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); @@ -145,5 +146,12 @@ module.exports = Merge.smart(commonConfig, { APPLE_APP_STORE_URL: null, GOOGLE_PLAY_URL: null, }), + new HtmlWebpackNewRelicPlugin({ + // This plugin fixes an issue where the newrelic script will break if + // not added directly to the HTML. + // We use non empty strings as defaults here to prevent errors for empty configs + license: process.env.NEW_RELIC_LICENSE_KEY || 'fake_app', + applicationID: process.env.NEW_RELIC_APP_ID || 'fake_license', + }), ], }); diff --git a/package-lock.json b/package-lock.json index 2f882ac..1535f3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2606,6 +2606,32 @@ "prop-types": "^15.5.10" } }, + "@newrelic/koa": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@newrelic/koa/-/koa-1.0.8.tgz", + "integrity": "sha512-kY//FlLQkGdUIKEeGJlyY3dJRU63EG77YIa48ACMGZxQbWRd3WZMikyft33f8XScTq6WpCDo9xa0viNo8zeYkg==", + "requires": { + "methods": "^1.1.2" + } + }, + "@newrelic/native-metrics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@newrelic/native-metrics/-/native-metrics-4.1.0.tgz", + "integrity": "sha512-7CZlKMLuaYQW7mV9qVyo9b9HVe2xBnyn+kkETRJoZGs5P7gdfv9AAE3RPhtOBUopTfbmc8ju7njYadjui9J1XA==", + "optional": true, + "requires": { + "nan": "^2.12.1", + "semver": "^5.5.1" + } + }, + "@newrelic/superagent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@newrelic/superagent/-/superagent-1.0.2.tgz", + "integrity": "sha512-B2fM48kfY+5L6pwk6Yt79yk1JzMWKu1wV73If2shAzMsujNqSA4P+mLKCnTthIuKlhE78OfB1MzSpRMeB7kgWw==", + "requires": { + "methods": "^1.1.2" + } + }, "@redux-saga/core": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.0.1.tgz", @@ -2722,6 +2748,11 @@ "source-map": "^0.6.0" } }, + "@tyriar/fibonacci-heap": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz", + "integrity": "sha512-bYuSNomfn4hu2tPiDN+JZtnzCpSpbJ/PNeulmocDy3xN2X5OkJL65zo6rPZp65cPPhLF9vfT/dgE+RtFRCSxOA==" + }, "@webassemblyjs/ast": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.11.tgz", @@ -2987,6 +3018,14 @@ "integrity": "sha512-z55ocwKBRLryBs394Sm3ushTtBeg6VAeuku7utSoSnsJKvKcnXFIyC6vh27n3rXyxSgkJBBCAvyOn7gSUcTYjg==", "dev": true }, + "agent-base": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", + "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "requires": { + "es6-promisify": "^5.0.0" + } + }, "airbnb-prop-types": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.11.0.tgz", @@ -7672,6 +7711,19 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz", + "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==" + }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "requires": { + "es6-promise": "^4.0.3" + } + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -9273,8 +9325,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -9295,14 +9346,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9317,20 +9366,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -9447,8 +9493,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -9460,7 +9505,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -9475,7 +9519,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -9483,14 +9526,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -9509,7 +9550,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -9590,8 +9630,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -9603,7 +9642,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -9689,8 +9727,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -9726,7 +9763,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -9746,7 +9782,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -9790,14 +9825,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -10434,6 +10467,15 @@ "mkdirp": "^0.5.1" } }, + "html-webpack-new-relic-plugin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/html-webpack-new-relic-plugin/-/html-webpack-new-relic-plugin-1.0.1.tgz", + "integrity": "sha512-BT0Un4Ykp5jwed1hE61zqtm2PKGddufbQZ1om01HItW6bJ8arz5MeDb6PZWmtznNYbM9x8NqSdmvfUC+dWivLg==", + "dev": true, + "requires": { + "cheerio": "^1.0.0-rc.2" + } + }, "html-webpack-plugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", @@ -10857,6 +10899,15 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "https-proxy-agent": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", + "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "requires": { + "agent-base": "^4.1.0", + "debug": "^3.1.0" + } + }, "humps": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", @@ -12561,8 +12612,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.2", @@ -13202,8 +13252,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", - "dev": true + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, "micromatch": { "version": "2.3.11", @@ -13490,8 +13539,7 @@ "nan": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.12.1.tgz", - "integrity": "sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==", - "dev": true + "integrity": "sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==" }, "nanomatch": { "version": "1.2.13", @@ -13571,6 +13619,62 @@ "integrity": "sha512-MFh0d/Wa7vkKO3Y3LlacqAEeHK0mckVqzDieUKTT+KGxi+zIpeVsFxymkIiRpbpDziHc290Xr9A1O4Om7otoRA==", "dev": true }, + "newrelic": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/newrelic/-/newrelic-5.5.0.tgz", + "integrity": "sha512-/grE+surOis796SHdDjQD+iWEcQ+r6p4eVRxEoQTvDg4wcmEklcvSTVyyyqdO2Nt8KCcM5CD+szBKg3RPuQIIg==", + "requires": { + "@newrelic/koa": "^1.0.8", + "@newrelic/native-metrics": "^4.0.0", + "@newrelic/superagent": "^1.0.2", + "@tyriar/fibonacci-heap": "^2.0.7", + "async": "^2.1.4", + "concat-stream": "^2.0.0", + "https-proxy-agent": "^2.2.1", + "json-stringify-safe": "^5.0.0", + "readable-stream": "^3.1.1", + "semver": "^5.3.0" + }, + "dependencies": { + "async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz", + "integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==", + "requires": { + "lodash": "^4.17.11" + } + }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "readable-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.2.0.tgz", + "integrity": "sha512-RV20kLjdmpZuTF1INEb9IA3L68Nmi+Ri7ppZqo78wj//Pn62fCoJyV9zalccNzDD/OuJpMG4f+pfMl8+L6QdGw==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "string_decoder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz", + "integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -19299,8 +19403,7 @@ "semver": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", - "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", - "dev": true + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" }, "semver-diff": { "version": "2.1.0", diff --git a/package.json b/package.json index c6cb1c8..a7a8b14 100755 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "iso-countries-languages": "^0.2.1", "lodash.camelcase": "^4.3.0", "lodash.snakecase": "^4.1.1", + "newrelic": "^5.5.0", "prop-types": "^15.5.10", "query-string": "^5.1.1", "react": "^16.8.3", @@ -88,6 +89,7 @@ "fetch-mock": "^6.3.0", "file-loader": "^1.1.9", "html-webpack-harddisk-plugin": "^0.2.0", + "html-webpack-new-relic-plugin": "^1.0.1", "html-webpack-plugin": "^3.0.3", "husky": "^0.14.3", "identity-obj-proxy": "^3.0.0", diff --git a/src/analytics/analytics.js b/src/analytics/analytics.js index 6128db1..35c34ac 100755 --- a/src/analytics/analytics.js +++ b/src/analytics/analytics.js @@ -1,6 +1,7 @@ import apiClient from '../config/apiClient'; import { configuration } from '../config/environment'; import { snakeCaseObject } from '../services/utils'; +import LoggingService from '../services/LoggingService'; const eventLogApiBaseUrl = `${configuration.LMS_BASE_URL}/event`; @@ -14,14 +15,16 @@ function handleTrackEvents(eventName, properties) { // Sends events to tracking log and downstream // TODO: Determine consistent naming for eventName vs eventType and properties v eventData. function logEvent(eventType, eventData) { - snakeCaseObject(eventData, { deep: true }); + const snakeEventData = snakeCaseObject(eventData, { deep: true }); const serverData = { event_type: eventType, - event: eventData, + event: snakeEventData, page: window.location.href, }; - // TODO: ARCH-430: Send errors to New Relic. - return apiClient.post(eventLogApiBaseUrl, serverData); + return apiClient.post(eventLogApiBaseUrl, serverData) + .catch((error) => { + LoggingService.logAPIErrorResponse(error); + }); } diff --git a/src/analytics/analytics.test.js b/src/analytics/analytics.test.js new file mode 100644 index 0000000..f837923 --- /dev/null +++ b/src/analytics/analytics.test.js @@ -0,0 +1,57 @@ +import { logEvent } from './analytics'; +import { configuration } from '../config/environment'; +import LoggingService from '../services/LoggingService'; +import apiClient from '../config/apiClient'; + +jest.mock('../services/LoggingService'); +jest.mock('../config/apiClient'); + +const eventType = 'test.event'; +const eventData = { + testShallow: 'test-shallow', + testObject: { + testDeep: 'test-deep', + }, +}; + + +beforeAll(() => { + apiClient.mockClear(); + LoggingService.mockClear(); +}); + + +describe('analytics logEvent', () => { + it('posts expected data when successful', () => { + jest.spyOn(apiClient, 'post').mockResolvedValue(undefined); + + expect.assertions(3); + return logEvent(eventType, eventData) + .then(() => { + expect(apiClient.post.mock.calls.length).toEqual(1); + expect(apiClient.post.mock.calls[0][0]).toEqual(`${configuration.LMS_BASE_URL}/event`); + expect(apiClient.post.mock.calls[0][1]).toEqual({ + event_type: 'test.event', + event: { + test_shallow: 'test-shallow', + test_object: { + test_deep: 'test-deep', + }, + }, + page: window.location.href, + }); + }); + }); + + it('calls LoggingService.logAPIErrorResponse on error', () => { + LoggingService.logAPIErrorResponse = jest.fn(); + jest.spyOn(apiClient, 'post').mockRejectedValue('test-error'); + + expect.assertions(2); + return logEvent(eventType, eventData) + .then(() => { + expect(LoggingService.logAPIErrorResponse.mock.calls.length).toBe(1); + expect(LoggingService.logAPIErrorResponse.mock.calls[0][0]).toEqual('test-error'); + }); + }); +}); diff --git a/src/services/LoggingService.js b/src/services/LoggingService.js new file mode 100644 index 0000000..aac624e --- /dev/null +++ b/src/services/LoggingService.js @@ -0,0 +1,32 @@ +/** + * Logs info and errors to NewRelic and console. + * + * Requires the NewRelic Browser JavaScript snippet. + */ +class LoggingService { + static logInfo(message) { + if (typeof newrelic !== 'undefined') { + newrelic.addPageAction('INFO', { message }); + } + } + + static logError(error) { + if (typeof newrelic !== 'undefined') { + newrelic.noticeError(error); + } + } + + static logAPIErrorResponse(error) { + let { message } = error; + if (error.response) { + message = `${error.response.status} ${error.response.config.url} ${JSON.stringify(error.response.data)}`; + } else if (error.request) { + message = `${error.request.status} ${error.request.responseURL} ${error.request.responseText}`; + } else if (error.stack) { + message = error.stack; + } + this.logError(new Error(`API request failed: ${message}`)); + } +} + +export default LoggingService; diff --git a/src/services/LoggingService.test.js b/src/services/LoggingService.test.js new file mode 100644 index 0000000..280c6c1 --- /dev/null +++ b/src/services/LoggingService.test.js @@ -0,0 +1,66 @@ +import LoggingService from './LoggingService'; + +global.newrelic = { + addPageAction: jest.fn(), + noticeError: jest.fn(), +}; + +describe('logInfo', () => { + it('calls New Relic client to log message if the client is available', () => { + const message = 'Test log'; + LoggingService.logInfo(message); + expect(global.newrelic.addPageAction).toHaveBeenCalledWith('INFO', { message }); + }); +}); + +describe('logError', () => { + it('calls New Relic client to log error if the client is available', () => { + const error = new Error('Failed!'); + LoggingService.logError(error); + expect(global.newrelic.noticeError).toHaveBeenCalledWith(error); + }); +}); + +describe('logAPIErrorResponse', () => { + it('calls New Relic client to log error when error has request object', () => { + const error = { + request: { + status: 400, + responseURL: 'http://example.com', + responseText: 'Very bad request', + }, + }; + const message = `${error.request.status} ${error.request.responseURL} ${error.request.responseText}`; + const expectedError = new Error(`API request failed: ${message}`); + LoggingService.logAPIErrorResponse(error); + expect(global.newrelic.noticeError).toHaveBeenCalledWith(expectedError); + }); + it('calls New Relic client to log error when error has response object', () => { + const error = { + response: { + status: 400, + config: { + url: 'http://example.com', + }, + data: { + detail: 'Very bad request', + }, + }, + }; + const message = `${error.response.status} ${error.response.config.url} ${JSON.stringify(error.response.data)}`; + const expectedError = new Error(`API request failed: ${message}`); + LoggingService.logAPIErrorResponse(error); + expect(global.newrelic.noticeError).toHaveBeenCalledWith(expectedError); + }); + it('calls New Relic client to log error when error has stack object', () => { + const error = { + stack: `TypeError: Cannot read property 'uuid' of undefined + at portalConfiguration (webpack:///./src/data/reducers/portalConfiguration.js?:35:43) + at combination (webpack:///./node_modules/redux/es/combineReducers.js?:125:29) + at dispatch (webpack:///./node_modules/redux/es/createStore.js?:170:22)`, + }; + const expectedError = new Error(`API request failed: ${error.stack}`); + LoggingService.logAPIErrorResponse(error); + expect(global.newrelic.noticeError).toHaveBeenCalledWith(expectedError); + }); +});