packages/core/client.js
const config = require('./config')
const Event = require('./event')
const Breadcrumb = require('./breadcrumb')
const Session = require('./session')
const map = require('./lib/es-utils/map')
const includes = require('./lib/es-utils/includes')
const filter = require('./lib/es-utils/filter')
const reduce = require('./lib/es-utils/reduce')
const keys = require('./lib/es-utils/keys')
const assign = require('./lib/es-utils/assign')
const runCallbacks = require('./lib/callback-runner')
const metadataDelegate = require('./lib/metadata-delegate')
const runSyncCallbacks = require('./lib/sync-callback-runner')
const BREADCRUMB_TYPES = require('./lib/breadcrumb-types')
const { add, clear, merge } = require('./lib/feature-flag-delegate')
const noop = () => {}
class Client {
constructor (configuration, schema = config.schema, internalPlugins = [], notifier) {
// notifier id
this._notifier = notifier
// intialise opts and config
this._config = {}
this._schema = schema
// i/o
this._delivery = { sendSession: noop, sendEvent: noop }
this._logger = { debug: noop, info: noop, warn: noop, error: noop }
// plugins
this._plugins = {}
// state
this._breadcrumbs = []
this._session = null
this._metadata = {}
this._featuresIndex = {}
this._features = []
this._context = undefined
this._user = {}
// callbacks:
// e: onError
// s: onSession
// sp: onSessionPayload
// b: onBreadcrumb
// (note these names are minified by hand because object
// properties are not safe to minify automatically)
this._cbs = {
e: [],
s: [],
sp: [],
b: []
}
// expose internal constructors
this.Client = Client
this.Event = Event
this.Breadcrumb = Breadcrumb
this.Session = Session
this._config = this._configure(configuration, internalPlugins)
map(internalPlugins.concat(this._config.plugins), pl => {
if (pl) this._loadPlugin(pl)
})
// when notify() is called we need to know how many frames are from our own source
// this inital value is 1 not 0 because we wrap notify() to ensure it is always
// bound to have the client as its `this` value – see below.
this._depth = 1
const self = this
const notify = this.notify
this.notify = function () {
return notify.apply(self, arguments)
}
}
addMetadata (section, keyOrObj, maybeVal) {
return metadataDelegate.add(this._metadata, section, keyOrObj, maybeVal)
}
getMetadata (section, key) {
return metadataDelegate.get(this._metadata, section, key)
}
clearMetadata (section, key) {
return metadataDelegate.clear(this._metadata, section, key)
}
addFeatureFlag (name, variant = null) {
add(this._features, this._featuresIndex, name, variant)
}
addFeatureFlags (featureFlags) {
merge(this._features, featureFlags, this._featuresIndex)
}
clearFeatureFlag (name) {
clear(this._features, this._featuresIndex, name)
}
clearFeatureFlags () {
this._features = []
this._featuresIndex = {}
}
getContext () {
return this._context
}
setContext (c) {
this._context = c
}
_configure (opts, internalPlugins) {
const schema = reduce(internalPlugins, (schema, plugin) => {
if (plugin && plugin.configSchema) return assign({}, schema, plugin.configSchema)
return schema
}, this._schema)
// accumulate configuration and error messages
const { errors, config } = reduce(keys(schema), (accum, key) => {
const defaultValue = schema[key].defaultValue(opts[key])
if (opts[key] !== undefined) {
const valid = schema[key].validate(opts[key])
if (!valid) {
accum.errors[key] = schema[key].message
accum.config[key] = defaultValue
} else {
if (schema[key].allowPartialObject) {
accum.config[key] = assign(defaultValue, opts[key])
} else {
accum.config[key] = opts[key]
}
}
} else {
accum.config[key] = defaultValue
}
return accum
}, { errors: {}, config: {} })
if (schema.apiKey) {
// missing api key is the only fatal error
if (!config.apiKey) throw new Error('No Bugsnag API Key set')
// warn about an apikey that is not of the expected format
if (!/^[0-9a-f]{32}$/i.test(config.apiKey)) errors.apiKey = 'should be a string of 32 hexadecimal characters'
}
// update and elevate some options
this._metadata = assign({}, config.metadata)
merge(this._features, config.featureFlags, this._featuresIndex)
this._user = assign({}, config.user)
this._context = config.context
if (config.logger) this._logger = config.logger
// add callbacks
if (config.onError) this._cbs.e = this._cbs.e.concat(config.onError)
if (config.onBreadcrumb) this._cbs.b = this._cbs.b.concat(config.onBreadcrumb)
if (config.onSession) this._cbs.s = this._cbs.s.concat(config.onSession)
// finally warn about any invalid config where we fell back to the default
if (keys(errors).length) {
this._logger.warn(generateConfigErrorMessage(errors, opts))
}
return config
}
getUser () {
return this._user
}
setUser (id, email, name) {
this._user = { id, email, name }
}
_loadPlugin (plugin) {
const result = plugin.load(this)
// JS objects are not the safest way to store arbitrarily keyed values,
// so bookend the key with some characters that prevent tampering with
// stuff like __proto__ etc. (only store the result if the plugin had a
// name)
if (plugin.name) this._plugins[`~${plugin.name}~`] = result
return this
}
getPlugin (name) {
return this._plugins[`~${name}~`]
}
_setDelivery (d) {
this._delivery = d(this)
}
startSession () {
const session = new Session()
session.app.releaseStage = this._config.releaseStage
session.app.version = this._config.appVersion
session.app.type = this._config.appType
session._user = assign({}, this._user)
// run onSession callbacks
const ignore = runSyncCallbacks(this._cbs.s, session, 'onSession', this._logger)
if (ignore) {
this._logger.debug('Session not started due to onSession callback')
return this
}
return this._sessionDelegate.startSession(this, session)
}
addOnError (fn, front = false) {
this._cbs.e[front ? 'unshift' : 'push'](fn)
}
removeOnError (fn) {
this._cbs.e = filter(this._cbs.e, f => f !== fn)
}
_addOnSessionPayload (fn) {
this._cbs.sp.push(fn)
}
addOnSession (fn) {
this._cbs.s.push(fn)
}
removeOnSession (fn) {
this._cbs.s = filter(this._cbs.s, f => f !== fn)
}
addOnBreadcrumb (fn, front = false) {
this._cbs.b[front ? 'unshift' : 'push'](fn)
}
removeOnBreadcrumb (fn) {
this._cbs.b = filter(this._cbs.b, f => f !== fn)
}
pauseSession () {
return this._sessionDelegate.pauseSession(this)
}
resumeSession () {
return this._sessionDelegate.resumeSession(this)
}
leaveBreadcrumb (message, metadata, type) {
// coerce bad values so that the defaults get set
message = typeof message === 'string' ? message : ''
type = (typeof type === 'string' && includes(BREADCRUMB_TYPES, type)) ? type : 'manual'
metadata = typeof metadata === 'object' && metadata !== null ? metadata : {}
// if no message, discard
if (!message) return
const crumb = new Breadcrumb(message, metadata, type)
// run onBreadcrumb callbacks
const ignore = runSyncCallbacks(this._cbs.b, crumb, 'onBreadcrumb', this._logger)
if (ignore) {
this._logger.debug('Breadcrumb not attached due to onBreadcrumb callback')
return
}
// push the valid crumb onto the queue and maintain the length
this._breadcrumbs.push(crumb)
if (this._breadcrumbs.length > this._config.maxBreadcrumbs) {
this._breadcrumbs = this._breadcrumbs.slice(this._breadcrumbs.length - this._config.maxBreadcrumbs)
}
}
_isBreadcrumbTypeEnabled (type) {
const types = this._config.enabledBreadcrumbTypes
return types === null || includes(types, type)
}
notify (maybeError, onError, postReportCallback = noop) {
const event = Event.create(maybeError, true, undefined, 'notify()', this._depth + 1, this._logger)
this._notify(event, onError, postReportCallback)
}
_notify (event, onError, postReportCallback = noop) {
event.app = assign({}, event.app, {
releaseStage: this._config.releaseStage,
version: this._config.appVersion,
type: this._config.appType
})
event.context = event.context || this._context
event._metadata = assign({}, event._metadata, this._metadata)
event._user = assign({}, event._user, this._user)
event.breadcrumbs = this._breadcrumbs.slice()
merge(event._features, this._features, event._featuresIndex)
// exit early if events should not be sent on the current releaseStage
if (this._config.enabledReleaseStages !== null && !includes(this._config.enabledReleaseStages, this._config.releaseStage)) {
this._logger.warn('Event not sent due to releaseStage/enabledReleaseStages configuration')
return postReportCallback(null, event)
}
const originalSeverity = event.severity
const onCallbackError = err => {
// errors in callbacks are tolerated but we want to log them out
this._logger.error('Error occurred in onError callback, continuing anyway…')
this._logger.error(err)
}
const callbacks = [].concat(this._cbs.e).concat(onError)
runCallbacks(callbacks, event, onCallbackError, (err, shouldSend) => {
if (err) onCallbackError(err)
if (!shouldSend) {
this._logger.debug('Event not sent due to onError callback')
return postReportCallback(null, event)
}
if (this._isBreadcrumbTypeEnabled('error')) {
// only leave a crumb for the error if actually got sent
Client.prototype.leaveBreadcrumb.call(this, event.errors[0].errorClass, {
errorClass: event.errors[0].errorClass,
errorMessage: event.errors[0].errorMessage,
severity: event.severity
}, 'error')
}
if (originalSeverity !== event.severity) {
event._handledState.severityReason = { type: 'userCallbackSetSeverity' }
}
if (event.unhandled !== event._handledState.unhandled) {
event._handledState.severityReason.unhandledOverridden = true
event._handledState.unhandled = event.unhandled
}
if (this._session) {
this._session._track(event)
event._session = this._session
}
this._delivery.sendEvent({
apiKey: event.apiKey || this._config.apiKey,
notifier: this._notifier,
events: [event]
}, (err) => postReportCallback(err, event))
})
}
}
const generateConfigErrorMessage = (errors, rawInput) => {
const er = new Error(
`Invalid configuration\n${map(keys(errors), key => ` - ${key} ${errors[key]}, got ${stringify(rawInput[key])}`).join('\n\n')}`)
return er
}
const stringify = val => {
switch (typeof val) {
case 'string':
case 'number':
case 'object':
return JSON.stringify(val)
default: return String(val)
}
}
module.exports = Client