Ghost/ghost/admin/app/services/notifications.js
Kevin Ansfield 9c3ddf4b2b Added additional error reporting and context to Sentry
refs https://github.com/TryGhost/Team/issues/1121

- adds error reporting for `notifications.showAlert()` in addition to `notifications.showAPIError()` because the former is used for some handled errors but will still show the undesired red error bar
- adds `shown_to_user` tag to all reported errors. Only errors that result in the red error bar will be reported with the tag as `true`, there may still be cases where we display errors outside of the red error bar but those wouldn't make it to Sentry either, we'll need a full audit of Admin source to find those situations and handle the reporting manually
- adds `ghost` context to errors that are shown in the red bar
  - `ghost_error_code` - code returned from the API or passed manually through the `notifications.showAPIError()` options argument. Will be filled in once the error message has been audited+approved
  - `displayed_message` - the full error message displayed to the user. Admin combines multiple attributes from the error details so it's useful to see what we actually show
  - `full_error` - the actual API error response or a client-side generated error object
  - `source` - whether the red bar came from `.showAlert()` or `.showAPIError()`, useful for tracking down where errors originated
2021-11-08 12:34:23 +00:00

250 lines
7.8 KiB
JavaScript

import * as Sentry from '@sentry/browser';
import Service, {inject as service} from '@ember/service';
import {dasherize} from '@ember/string';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
import {filter} from '@ember/object/computed';
import {get, set} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {isBlank} from '@ember/utils';
import {
isMaintenanceError,
isVersionMismatchError
} from 'ghost-admin/services/ajax';
// Notification keys take the form of "noun.verb.message", eg:
//
// "invite.resend.api-error"
// "user.invite.already-invited"
//
// The "noun.verb" part will be used as the "key base" in duplicate checks
// to avoid stacking of multiple error messages whilst leaving enough
// specificity to re-use keys for i18n lookups
export default Service.extend({
delayedNotifications: null,
content: null,
init() {
this._super(...arguments);
this.delayedNotifications = emberA();
this.content = emberA();
},
config: service(),
upgradeStatus: service(),
alerts: filter('content', function (notification) {
let status = get(notification, 'status');
return status === 'alert';
}),
notifications: filter('content', function (notification) {
let status = get(notification, 'status');
return status === 'notification';
}),
handleNotification(message, delayed) {
// If this is an alert message from the server, treat it as html safe
if (message.constructor.modelName === 'notification' && message.get('status') === 'alert') {
message.set('message', htmlSafe(message.get('message')));
}
if (!get(message, 'status')) {
set(message, 'status', 'notification');
}
// close existing duplicate alerts/notifications to avoid stacking
if (get(message, 'key')) {
this._removeItems(get(message, 'status'), get(message, 'key'));
}
// close existing alerts/notifications which have the same text to avoid stacking
let newText = get(message, 'message').string || get(message, 'message');
this.set('content', this.content.reject((notification) => {
let existingText = get(notification, 'message').string || get(notification, 'message');
return existingText === newText;
}));
if (!delayed) {
this.content.pushObject(message);
} else {
this.delayedNotifications.pushObject(message);
}
},
showAlert(message, options) {
options = options || {};
if (!options.isApiError) {
if (this.config.get('sentry_dsn')) {
// message could be a htmlSafe object rather than a string
const displayedMessage = get(message, 'string') || message;
const contexts = {
ghost: {
displayed_message: displayedMessage,
ghost_error_code: get(options, 'ghostErrorCode'),
full_error: message,
source: 'showAlert'
}
};
Sentry.captureException(displayedMessage, {
contexts,
tags: {
shown_to_user: true
}
});
}
}
this.handleNotification({
message,
status: 'alert',
description: options.description,
icon: options.icon,
type: options.type,
key: options.key,
actions: options.actions
}, options.delayed);
},
showNotification(message, options) {
options = options || {};
this.handleNotification({
message,
status: 'notification',
description: options.description,
icon: options.icon,
type: options.type,
key: options.key,
actions: options.actions
}, options.delayed);
},
showAPIError(resp, options) {
// handle "global" errors
if (isVersionMismatchError(resp)) {
return this.upgradeStatus.requireUpgrade();
} else if (isMaintenanceError(resp)) {
return this.upgradeStatus.maintenanceAlert();
}
// loop over ember-ajax errors object
if (resp && resp.payload && isEmberArray(resp.payload.errors)) {
return resp.payload.errors.forEach((error) => {
this._showAPIError(error, options);
});
}
this._showAPIError(resp, options);
},
_showAPIError(resp, options) {
options = options || {};
options.type = options.type || 'error';
// if possible use the title to get a unique key
// - we only show one alert for each key so if we get multiple errors
// only the last one will be shown
if (!options.key && !isBlank(get(resp, 'title'))) {
options.key = dasherize(get(resp, 'title'));
}
options.key = ['api-error', options.key].compact().join('.');
let msg = options.defaultErrorText || 'There was a problem on the server, please try again.';
if (resp instanceof String) {
msg = resp;
} else if (!isBlank(get(resp, 'detail'))) {
msg = resp.detail;
} else if (!isBlank(get(resp, 'message'))) {
msg = resp.message;
}
if (!isBlank(get(resp, 'context'))) {
msg = `${msg} ${get(resp, 'context')}`;
}
if (this.config.get('sentry_dsn')) {
const reportedError = resp instanceof Error ? resp : msg;
Sentry.captureException(reportedError, {
contexts: {
ghost: {
ghost_error_code: get(resp, 'ghostErrorCode'),
displayed_message: msg,
full_error: resp,
source: 'showAPIError'
}
},
tags: {
shown_to_user: true
}
});
}
options.isApiError = true;
this.showAlert(msg, options);
},
displayDelayed() {
this.delayedNotifications.forEach((message) => {
this.content.pushObject(message);
});
this.set('delayedNotifications', []);
},
closeNotification(notification) {
let content = this.content;
if (notification.constructor.modelName === 'notification') {
notification.deleteRecord();
notification.save().finally(() => {
content.removeObject(notification);
});
} else {
content.removeObject(notification);
}
},
closeNotifications(key) {
this._removeItems('notification', key);
},
closeAlerts(key) {
this._removeItems('alert', key);
},
clearAll() {
this.content.clear();
},
_removeItems(status, key) {
if (key) {
let keyBase = this._getKeyBase(key);
// TODO: keys should only have . special char but we should
// probably use a better regexp escaping function/polyfill
let escapedKeyBase = keyBase.replace('.', '\\.');
let keyRegex = new RegExp(`^${escapedKeyBase}`);
this.set('content', this.content.reject((item) => {
let itemKey = get(item, 'key');
let itemStatus = get(item, 'status');
return itemStatus === status && (itemKey && itemKey.match(keyRegex));
}));
} else {
this.set('content', this.content.rejectBy('status', status));
}
},
// take a key and return the first two elements, eg:
// "invite.revoke.failed" => "invite.revoke"
_getKeyBase(key) {
return key.split('.').slice(0, 2).join('.');
}
});