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
This commit is contained in:
Robert Raposa
2019-02-27 16:50:51 -05:00
parent b24f6db050
commit f9f1c723cd
9 changed files with 322 additions and 47 deletions

View File

@@ -20,5 +20,8 @@
},
"env": {
"jest": true
},
"globals": {
"newrelic": false
}
}

View File

@@ -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: [

View File

@@ -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',
}),
],
});

179
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
});
}

View File

@@ -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');
});
});
});

View File

@@ -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;

View File

@@ -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);
});
});