core/utils/sentry.js
/** Sentry monitoring support
*
* @module core/utils/sentry
* @flow
*
* Setup our Sentry integration to send errors and crash reports to our Sentry
* server.
*
* Follow these steps to upload Electron debug symbols after each version
* upgrade:
*
* 1. `sentry-wizard --skip-connect -u <server url> --integration electron
* 2. Follow the steps (auth tokens can be generated here: https://<server url>/settings/account/api/auth-tokens/)
* 3. `node sentry-symbols.js`
*/
const { session } = require('electron')
const Sentry = require('@sentry/electron')
const {
ExtraErrorData: ExtraErrorDataIntegration
} = require('@sentry/integrations')
const bunyan = require('bunyan')
const url = require('url')
const _ = require('lodash')
const { SESSION_PARTITION_NAME } = require('../../gui/js/network')
const { HOURS } = require('./time')
const logger = require('./logger')
const log = logger({
component: 'Sentry'
})
module.exports = {
setup,
flag,
format,
toSentryContext
}
const { CI, COZY_NO_SENTRY, DEBUG } = process.env
const SENTRY_REF = `e937e35fcaa14c9a84ca5980ef8a852e`
const SENTRY_DSN = `https://${SENTRY_REF}@errors.cozycloud.cc/4`
const DOMAIN_TO_ENV = {
'cozy.localhost': 'development',
'cozy.works': 'development',
'cozy.rocks': 'production',
'mycozy.cloud': 'production'
}
function toSentryContext(cozyUrl /*: string */) {
const host = cozyUrl && new url.URL(cozyUrl).host
if (!host) throw new Error('badly formated URL')
const urlParts = host.split(':')[0].split('.')
const domain = urlParts.slice(-2).join('.')
const instance = urlParts.slice(-3).join('.')
const environment = DOMAIN_TO_ENV[domain] || 'selfhost'
return { domain, instance, environment }
}
let isSentryConfigured = false
/*::
import type { ClientInfo } from '../app'
*/
let ErrorsAlreadySent /* Map<string,Date> */
function setup(clientInfos /*: ClientInfo */) {
if (CI || COZY_NO_SENTRY || isSentryConfigured) {
log.info(
{ COZY_NO_SENTRY, isSentryConfigured },
'skipping Sentry configuration'
)
if (DEBUG) {
// eslint-disable-next-line no-console
console.log(
{ COZY_NO_SENTRY, isSentryConfigured },
'skipping Sentry configuration'
)
}
return
}
try {
const { appVersion, cozyUrl } = clientInfos
const { domain, instance, environment } = toSentryContext(cozyUrl)
Sentry.init({
dsn: SENTRY_DSN,
release: appVersion,
environment,
// Inject preload script into all used sessions
getSessions: () => [
session.defaultSession,
session.fromPartition(SESSION_PARTITION_NAME)
],
// Adding the ElectronMinidump integration like this
// ensures that it is the first integrations to be initialized.
integrations: defaultIntegrations => {
return [
// Uploads minidumps via Crashpad/Breakpad built in uploader with
// partial context when reporting native crash.
new Sentry.Integrations.ElectronMinidump(),
// Extract all non-native attributes up to <depth> from Error objects
// and attach them to events as extra data.
// If the error object has a .toJSON() method, it will be run to
// extract the additional data.
new ExtraErrorDataIntegration({ depth: 10 }),
...defaultIntegrations
]
},
beforeSend: (event, hint) => {
const error = hint.originalException
const message = error && error.message ? error.message : event.message
const alreadySentThisDay =
Number(ErrorsAlreadySent.get(message)) > Date.now() - 24 * HOURS
// Update the last send date for this message
ErrorsAlreadySent.set(message, Date.now())
// Drop events if a similar message has already been sent if the past
// 24 hours (i.e. avoid spamming our Sentry server).
return alreadySentThisDay ? null : event
}
})
Sentry.configureScope(scope => {
scope.setUser({ username: instance })
scope.setTag('domain', domain)
scope.setTag('instance', instance)
scope.setTag('server_name', clientInfos.deviceName)
})
logger.defaultLogger.addStream({
type: 'raw',
stream: {
write: msg => {
try {
handleBunyanMessage(msg)
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error in handleBunyanMessage', err)
}
}
}
})
ErrorsAlreadySent = new Map()
isSentryConfigured = true
log.info('Sentry configured !')
// Cleanup errors journal to prevent an ever growing Map
setInterval(() => {
for (const [msg, sentAt] of ErrorsAlreadySent.entries()) {
if (sentAt < Date.now() - 24 * HOURS) {
ErrorsAlreadySent.delete(msg)
}
}
}, 1 * HOURS)
} catch (err) {
// eslint-disable-next-line no-console
console.log('FAIL TO SETUP', err)
log.error(
{ err },
'Could not load Sentry, errors will not be sent to Sentry'
)
}
}
const handleBunyanMessage = msg => {
const level =
msg.level >= bunyan.FATAL
? 'fatal'
: msg.level >= bunyan.ERROR
? 'error'
: msg.level >= bunyan.WARNING
? 'warning'
: 'info'
if (!isSentryConfigured) return
// Send messages explicitly marked for sentry and all TypeError instances
if (msg.sentry || (msg.err && msg.err.name === 'TypeError')) {
const extra = _.omit(msg, [
'tags',
'v',
'hostname',
'sentry',
'pid',
'level'
])
Sentry.withScope(scope => {
scope.setLevel(level)
scope.setContext('msgDetails', extra)
if (msg.err) {
Sentry.captureException(format(msg.err))
} else {
Sentry.captureMessage(msg.msg)
}
})
} else {
// keep it as breadcrumb
Sentry.addBreadcrumb({
message: msg.msg,
category: msg.component,
data: _.omit(msg, [
'component',
'pid',
'name',
'hostname',
'level',
'v',
'msg'
]),
level
})
}
}
// TODO: make Flow happy with extended error type
function flag(err /*: Object */) {
err.sentry = true
return err
}
/**
* Make sure the given error object has the required attributes for Sentry to
* group events with the same error together via `exception` fingerprinting.
*
* @see https://docs.sentry.io/data-management/event-grouping/
*
* For more details on the available attributes:
* - @see {@link https://github.com/cozy/cozy-client-js/blob/master/src/fetch.js|cozy-client-js}
* - @see {@link https://github.com/bitinn/node-fetch/blob/master/ERROR-HANDLING.md|node-fetch}
* - @see {@link https://github.com/arantes555/electron-fetch/blob/master/ERROR-HANDLING.md|electron-fetch}
* - @see {@link https://nodejs.org/api/errors.html|Node.js}
*/
function format(err /*: Object */) {
switch (err.name) {
case 'FetchError':
if (err.reason) return cozyErrObjectToError(bunyanErrObjectToError(err))
else if (err.type)
return fetchErrObjectToError(bunyanErrObjectToError(err))
return bunyanErrObjectToError(err)
case 'Error':
if (err.code) return systemErrObjectToError(bunyanErrObjectToError(err))
else return bunyanErrObjectToError(err)
default:
return bunyanErrObjectToError(err)
}
}
function cozyErrObjectToError(err) {
switch (typeof err.reason) {
case 'string':
err.message = err.reason
break
case 'object':
err.message =
err.reason.errors && err.reason.errors.length
? err.reason.errors[0].detail
: err.reason.detail
break
}
err.type = err.type || 'FetchError'
return err
}
function fetchErrObjectToError(err) {
switch (err.type) {
case 'system':
case 'proxy':
err.message = err.code
break
default:
err.message = err.type
}
err.type = 'FetchError'
return err
}
function systemErrObjectToError(err) {
err.type = 'Error'
err.message = err.code
return err
}
function bunyanErrObjectToError(data) {
const error /*: Object */ = new Error(data.message)
for (const attr in data) {
error[attr] = data[attr]
}
if (!error.reason) error.reason = data.message
return error
}