sentry-javascript
Version:
A generic Javascript SDK based on Self-Hosted Sentry.
621 lines (620 loc) • 20 kB
text/typescript
import type {
BasicInitOptions,
SentryInitOptions,
ApiOptions,
StoreApiOptions,
HttpHeader,
UploadRequestOptions,
EnvelopeApiOptions,
EnvelopePayloadHeaderOptions,
EnvelopePayloadItemOptions,
LogLevel,
SentryCaptureOptions,
UserOptions,
TagOptions,
ExtraOptions,
ExceptionOptions,
StackTraceFrameItem,
BreadcrumbItem,
RequestOptions,
SentryScope,
ConfigureScopeCallback
} from './types/index'
import { outputMsg } from './utils/console'
import { isObject } from './utils/type'
import { deepMerge } from './utils/object'
import { isOversized } from './utils/size'
import { request } from './utils/request'
import { parseResponse, parseError, getBadRequestResponse, getResponseByCode } from './utils/response'
import { CUSTOM_STATUS_DISABLE_UPLOAD_LOG, HTTP_STATUS_PAYLOAY_TOO_LARGE } from './constants'
import ErrorStackParser from 'error-stack-parser'
import type { StackFrame } from 'error-stack-parser'
/** Log levels allowed to be set */
const allowLogLevels = ['fatal', 'error', 'warning', 'info', 'debug']
/** Breadcrumb types allowed to be set */
const allowBreadcrumbTypes = ['default', 'debug', 'error', 'navigation', 'http', 'info', 'query', 'user']
/** The regular expression of Sentry DSN */
const dsnReg = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+))?@)([\w.-]+)(?::(\d+))?\/(.+)/
/** The regular expression of Sentry project version number */
const releaseReg = /^.+@\d+\.\d+\.\d+$/
/** Sentry SDK version */
const sdkVersion = '1.0.1'
/** Sentry SDK name */
const sdkName = 'sentry.javascript.browser'
/** Sentry SDK basic initialization configuration */
const basicOptions: BasicInitOptions = {
dsn: '',// Sentry log service DSN.
enabled: true,// Whether to allow data to be reported.
debug: false,// Whether to enable debug mode.
platform: 'javascript',// A string representing the platform the SDK is submitting from.
level: 'error',// Log level, the available values are "fatal" | "error" | "warning" | "info" | "debug".
serverName: window.location.hostname,// Identifies the host from which the event was recorded.
environment: 'production',// Environments that send log data, default value is "production".
envelope: true,// Whether to use the envelope interface to report data.
release: ''// Version number, suggested format my-project-name@1.0.0.
}
/** Initialized Sentry scope user basic configuration */
const initUserOptions: UserOptions = {
ip_address: '{{auto}}'// User ip address, where the default is automatically obtained by the server.
}
/** Sentry scope user configuration */
let userOptions: UserOptions = {
...initUserOptions
}
/** Sentry scope tags configuration */
let tagOptions: TagOptions = {}
/** Sentry scope extra information configuration */
let extraOptions: ExtraOptions = {}
/** Sentry scope optional log level */
let optionalLevel = ''
/** Sentry scope breadcrumbs configuration */
let breadcrumbRecords: BreadcrumbItem[] = []
/**
* @method Get request configuration
*/
function getRequestOptions(): RequestOptions {
return {
method: 'GET',
url: window.location.href,
headers: {
Referer: window.document.referrer,
'User-Agent': window.navigator.userAgent
}
}
}
/**
* @method Set user information
* @document https://develop.sentry.dev/sdk/event-payloads/user/
*/
function setUser(options: UserOptions | null) {
if (typeof options !== 'object') {
basicOptions.debug && outputMsg('Method "setUser" must pass a object parameter, please check again!', 'error')
return
}
if (options) {
userOptions = {
...userOptions,
...options
}
} else {
userOptions = {
...initUserOptions
}
}
}
/**
* @method Set tags
*/
function setTag(key: string, value: string) {
if (typeof key !== 'string' || typeof value !== 'string') {
basicOptions.debug && outputMsg('Method "setTag" must pass two string parameter, please check again!', 'error')
return
}
tagOptions[key] = value
}
/**
* @method Remove Tags
*/
function removeTag(key: string) {
if (typeof key !== 'string') {
basicOptions.debug && outputMsg('The parameter "key" of method "removeTag" must pass a string value, please check again!', 'error')
return
}
delete tagOptions[key]
}
/**
* @method Set extra information
*/
function setExtra(key: string, value: any) {
if (typeof key !== 'string') {
basicOptions.debug && outputMsg('The parameter "key" of method "setExtra" must pass a string value, please check again!', 'error')
return
}
extraOptions[key] = value
}
/**
* @method Remove extra information
*/
function removeExtra(key: string) {
if (typeof key !== 'string') {
basicOptions.debug && outputMsg('The parameter "key" of method "removeExtra" must pass a string value, please check again!', 'error')
return
}
delete extraOptions[key]
}
/**
* @method Set log level
*/
function setLevel(level: LogLevel) {
if (!allowLogLevels.includes(level)) {
basicOptions.debug && outputMsg('The parameter "level" of method "setLevel" must pass a valid string value, please check again!', 'error')
return
}
optionalLevel = level
}
/**
* @method Add breadcrumb record
*/
function addBreadcrumb(breadcrumb: BreadcrumbItem) {
// Illegal breadcrumb record
if (!isObject(breadcrumb)) {
basicOptions.debug && outputMsg('Method "addBreadcrumb" must pass a object value, please check again!', 'error')
return
}
const {
type = 'default',
category = '',
message = '',
data = null,
level = 'info',
timestamp = new Date().toISOString()
} = breadcrumb
// Illegal type
if (typeof type !== 'undefined' && (typeof type !== 'string' || !allowBreadcrumbTypes.includes(type))) {
basicOptions.debug && outputMsg('The option parameter "type" in the method "addBreadcrumb" must be a valid string value, please check again!', 'error')
return
}
// Illegal category
if (!['undefined', 'string'].includes(typeof category)) {
basicOptions.debug && outputMsg('The option parameter "category" in the method "addBreadcrumb" must be a string value, please check again!', 'error')
return
}
// Illegal message
if (!['undefined', 'string'].includes(typeof message)) {
basicOptions.debug && outputMsg('The option parameter "message" in the method "addBreadcrumb" must be a string value, please check again!', 'error')
return
}
// Illegal data
if (!['undefined', 'object'].includes(typeof data)) {
basicOptions.debug && outputMsg('The option parameter "data" in the method "addBreadcrumb" must be a object value, please check again!', 'error')
return
}
// Illegal level
if (typeof level !== 'undefined' && (typeof level !== 'string' || !allowLogLevels.includes(level))) {
basicOptions.debug && outputMsg('The option parameter "level" in the method "addBreadcrumb" must be a valid string value, please check again!', 'error')
return
}
// Illegal timestamp
if (!['undefined', 'string', 'number'].includes(typeof timestamp)) {
basicOptions.debug && outputMsg('The option parameter "timestamp" in the method "addBreadcrumb" must be a valid string or number value, please check again!', 'error')
return
}
breadcrumbRecords.push({
type,
category,
message,
data,
level,
timestamp
})
}
/**
* @method Clear scope user configuration
*/
function clearUserOptions() {
userOptions = {
...initUserOptions
}
}
/**
* @method Clear scope tag configuration
*/
function clearTagOptions() {
tagOptions = {}
}
/**
* @method Clear scope extra configuration
*/
function clearExtraOptions() {
extraOptions = {}
}
/**
* @method Clear scope log level
*/
function clearLevel() {
optionalLevel = ''
}
/**
* @method Clear scope breadcrumbs configuration
*/
function clearBreadcrumbs() {
breadcrumbRecords = []
}
/**
* @method Clear all scope configurations
*/
function clear() {
// Clear scope log level
clearLevel()
// Clear scope user configuration
clearUserOptions()
// Clear scope tag configuration
clearTagOptions()
// Clear scope extra configuration
clearExtraOptions()
// Clear scope breadcrumbs configuration
clearBreadcrumbs()
}
/**
* @method Using the global scope
*/
export function configureScope(callback: ConfigureScopeCallback) {
if (typeof callback !== 'function') {
basicOptions.debug && outputMsg('Method "configureScope" must pass a function value on parameter "callback", please check again!', 'error')
return
}
const scope: SentryScope = {
setUser,
setTag,
removeTag,
setExtra,
removeExtra,
setLevel,
addBreadcrumb,
clearBreadcrumbs,
clear
}
callback(scope)
}
/**
* @method Initializing Sentry SDK
*/
export function init(options: SentryInitOptions) {
// Illegal configuration item object parameter
if (!isObject(options)) {
basicOptions.debug && outputMsg('Method "init" must pass a object value, please check again!', 'error')
return
}
// Illegal dsn parameter
if (typeof options.dsn !== 'string') {
basicOptions.debug && outputMsg('Method "init" must pass the value of "dsn" on options params, please check again!', 'error')
return
}
// Illegal dsn format
if (!dsnReg.test(options.dsn)) {
basicOptions.debug && outputMsg('"dsn" must be a valid value, please check again!', 'error')
return
}
// Set dsn
basicOptions.dsn = options.dsn
// Valid enabled parameter value
if (typeof options.enabled === 'boolean') {
basicOptions.enabled = options.enabled
}
// Valid envelope parameter value
if (typeof options.envelope === 'boolean') {
basicOptions.envelope = options.envelope
}
// Valid debug parameter value
if (typeof options.debug === 'boolean') {
basicOptions.debug = options.debug
}
// Valid environment parameter value
if (typeof options.environment === 'string') {
basicOptions.environment = options.environment
}
// The verification of release optional parameter value
if (typeof options.release === 'string') {
// Valid release parameter value
if (!releaseReg.test(options.release)) {
basicOptions.debug && outputMsg('The option parameter "release" in the method "init" must be a string in the format "my-project-name@1.0.0", please check again!', 'error')
return
}
basicOptions.release = options.release
}
}
/**
* @method Parse DSN address
*/
function parseDSN(): ApiOptions {
const matches: string[] | null = dsnReg.exec(basicOptions.dsn)
const nodes = matches ? matches.slice(1) : []
// const [protocol, publicKey, _ = '', host, port = '', projectId] = nodes
const protocol = nodes[0]
const publicKey = nodes[1]
const host = nodes[3]
const port = nodes[4] || ''
const projectId = nodes[5]
return {
uri: `${protocol}://${host}${port}/api/`,
publicKey: publicKey,
projectId: projectId
}
}
/**
* @method Get API address
*/
function getAPIAddress(): string {
const key: string = basicOptions.envelope ? 'envelope' : 'store'
const basicRequestOptions: ApiOptions = parseDSN()
const url = `${basicRequestOptions.uri}${basicRequestOptions.projectId}/${key}/?sentry_version=7&sentry_client=${sdkName}${sdkVersion}&sentry_key=${basicRequestOptions.publicKey}`
return url
}
/**
* @method Get breadcrumbs configuration
*/
function getBreadcrumbs(data: any): BreadcrumbItem[] {
return Array.isArray(data) && data.length > 0 ? data : breadcrumbRecords
}
/**
* @method Get store interface request configuration
*/
function getStoreOptions(options: SentryCaptureOptions) : UploadRequestOptions {
// Constructing request headers
const headers: HttpHeader = {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
// Deconstruct configuration items that need to be handled independently
const {
user = {},
request = {},
tags = {},
extra = {},
breadcrumbs = [],
level,
...restOptions
} = options
// Constructing target request data
const payload: StoreApiOptions = {
platform: basicOptions.platform,
level: typeof level === 'string' && allowLogLevels.includes(level) ? level : (optionalLevel || basicOptions.level) as LogLevel,
server_name: basicOptions.serverName,
environment: basicOptions.environment,
release: basicOptions.release,
timestamp: new Date().toISOString(),
user: {
...userOptions,
...user
},
sdk: {
name: sdkName,
version: sdkVersion
},
request: deepMerge(getRequestOptions(), request),
tags: {
...tagOptions,
...tags
},
extra: deepMerge(extraOptions, extra),
breadcrumbs: getBreadcrumbs(breadcrumbs),
...restOptions
}
return {
url: getAPIAddress(),
headers: headers,
payload: JSON.stringify(payload)
}
}
/**
* @method Get envelope interface request configuration
* @document https://develop.sentry.dev/sdk/envelopes/#serialization-format
*/
function getEnvelopeOptions(options: SentryCaptureOptions): UploadRequestOptions {
// Deconstruct configuration items that need to be handled independently
const {
user = {},
request = {},
tags = {},
extra = {},
breadcrumbs = [],
type = 'event',
level,
event_id = '',
...restOptions
} = options
const headers: HttpHeader = {}
// Constructing target request data
const targetPayload: EnvelopeApiOptions = {
platform: basicOptions.platform,
level: typeof level === 'string' && allowLogLevels.includes(level) ? level : (optionalLevel || basicOptions.level) as LogLevel,
server_name: basicOptions.serverName,
environment: basicOptions.environment,
release: basicOptions.release,
type: type,
user: {
...userOptions,
...user
},
request: deepMerge(getRequestOptions(), request),
tags: {
...tagOptions,
...tags
},
extra: deepMerge(extraOptions, extra),
breadcrumbs: getBreadcrumbs(breadcrumbs),
...restOptions
}
const payloadHeaders: EnvelopePayloadHeaderOptions = {
sent_at: new Date().toISOString(),
sdk: {
name: sdkName,
version: sdkVersion
},
...(event_id && {event_id: event_id})
}
const payloadItem: EnvelopePayloadItemOptions = {
type: type
}
const payload: string = JSON.stringify(payloadHeaders) + '\n' + JSON.stringify(payloadItem) + '\n' + JSON.stringify(targetPayload) + '\n'
return {
url: getAPIAddress(),
headers: headers,
payload: payload
}
}
/**
* @method Upload logs to the server
*/
async function uploadLog(options: SentryCaptureOptions) {
const requestOptions: UploadRequestOptions = basicOptions.envelope ? getEnvelopeOptions(options) : getStoreOptions(options)
const url = requestOptions.url
const headers: HttpHeader = requestOptions.headers
const payload = requestOptions.payload
// The uploaded log content exceeds the size limit
if (isOversized(payload)) {
const res = getResponseByCode(HTTP_STATUS_PAYLOAY_TOO_LARGE)
basicOptions.debug && outputMsg(res.message, 'error')
return Promise.resolve(res)
}
// Sending relevant data to the server
return request(url, {
method: 'POST',
headers: headers,
body: payload
})
.then(parseResponse)
.catch(parseError)
}
/**
* @method Capture message
* @document basic options => https://develop.sentry.dev/sdk/event-payloads
* @document message options => https://develop.sentry.dev/sdk/event-payloads/message/
*/
export function captureMessage(message: string, options?: LogLevel | SentryCaptureOptions) {
// If dsn is not configured, uploading is disabled
if (!basicOptions.dsn) {
const errMsg = 'Please check if the "init" method was called!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// Disable uploading logs
if (!basicOptions.enabled) {
const res = getResponseByCode(CUSTOM_STATUS_DISABLE_UPLOAD_LOG)
basicOptions.debug && outputMsg(res.message, 'warn')
return Promise.resolve(res)
}
// Illegal information data
if (typeof message !== 'string' || message === '') {
const errMsg = 'Method "captureMessage" must pass a valid string value on parameter "message", please check again!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// Preset configuration
const presetOptions: SentryCaptureOptions = {
level: (optionalLevel || 'info') as LogLevel
}
// If there is no optional configuration, send directly data.
if (typeof options === 'undefined') {
// upload log
return uploadLog({
message: message,
...presetOptions
})
}
// If there is an optional configuration item and it is a string, confirm that it is the correct log level configuration.
if (typeof options === 'string') {
// If log level is allowed to be set, update the configuration.
if (allowLogLevels.includes(options)) {
presetOptions.level = options
}
// upload log
return uploadLog({
message: message,
...presetOptions
})
}
// Illegal configuration parameter
if (!isObject(options)) {
const errMsg = 'Method "captureMessage" must pass a string or object value on parameter "options", please check again!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// If an optional configuration item exists, the message parameter is handled specially.
const { message: msg = message, ...restOptions } = options
// upload log
return uploadLog({
message: msg,
...presetOptions,
...restOptions
})
}
/**
* @method Capture exception
*/
export function captureException(err: Error, options?: SentryCaptureOptions) {
// If dsn is not configured, uploading is disabled
if (!basicOptions.dsn) {
const errMsg = 'Please check if the "init" method was called!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// Disable uploading logs
if (!basicOptions.enabled) {
const res = getResponseByCode(CUSTOM_STATUS_DISABLE_UPLOAD_LOG)
basicOptions.debug && outputMsg(res.message, 'warn')
return Promise.resolve(res)
}
// Illegal err parameter value
if (typeof err !== 'object' || !(err instanceof Error)) {
const errMsg = 'Method "captureException" must pass a stantard instance of Error class on parameter "err", please check again!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// Parsing to get the stack
const stackFrames: StackFrame[] = ErrorStackParser.parse(err)
let frames: StackTraceFrameItem[] = []
// Formatting stack information if a stack exists
if (stackFrames.length > 0) {
frames = stackFrames.map((item: StackFrame) => ({
function: item.functionName || '',
filename: item.fileName || '',
abs_path: item.fileName || '',
lineno: item.lineNumber,
colno: item.columnNumber,
in_app: true
}))
}
const exceptionOption: ExceptionOptions = {
values: [{
type: err.name,
value: err.message,
stacktrace: {
frames
}
}]
}
// If there is no optional configuration, send directly data.
if (typeof options === 'undefined') {
// upload log
return uploadLog({
exception: exceptionOption
})
}
// Illegal configuration parameter
if (!isObject(options)) {
const errMsg = 'Method "captureException" must pass a object value on parameter "options", please check again!'
basicOptions.debug && outputMsg(errMsg, 'error')
return Promise.resolve(getBadRequestResponse(errMsg))
}
// If an optional configuration item exists, the exception parameter is handled specially.
const { exception = {}, ...restOptions } = options
// upload log
return uploadLog({
exception: {
...exceptionOption,
...exception
},
...restOptions
})
}