async_hooks 模块在 v8.0.0 版本正式加入 Node.js 的实验性 API。我们也是在 v8.x.x 版本下投入生产环境进行使用。
那么什么是 async_hooks 呢?
async_hooks 提供了追踪异步资源的 API,这种异步资源是具有关联回调的对象。
简而言之,Async Hook 可以用来追踪异步回调。那么如何使用这种追踪能力,使用的过程中又有什么问题呢?
v8.x.x 版本下的 async_hooks 主要有两部分组成,一个是 AsyncHook 用以追踪生命周期,一个是 AsyncResource 用于创建异步资源。
const { createHook, AsyncResource, executionAsyncId } = require('async_hooks')
const hook = createHook({
init (asyncId, type, triggerAsyncId, resource) {},
before (asyncId) {},
after (asyncId) {},
destroy (asyncId) {}
})
hook.enable()
function fn () {
console.log(executionAsyncId())
}
const asyncResource = new AsyncResource('demo')
asyncResource.run(fn)
asyncResource.run(fn)
asyncResource.emitDestroy()
上面这段代码的含义和执行结果是:
demo
的异步资源。此时触发了 init 钩子,异步资源 id 为 asyncId
,类型为 type
(即 demo),异步资源的创建上下文 id 为 triggerAsyncId
,异步资源为 resource
。fn
函数两次,此时会触发 before 两次、after 两次,异步资源 id 为 asyncId
,此 asyncId
与 fn
函数内通过 executionAsyncId
取到的值相同。像我们常用的 async\await、promise 语法或请求这些异步操作的背后都是一个个的异步资源,也会触发这些生命周期钩子函数。
那么,我们就可以通过 init 钩子函数,通过异步资源创建上下文 triggerAsyncId
(父)向当前异步资源 asyncId
(子)这种指向关系,将异步调用串联起来,拿到一棵完整的调用树,通过回调函数(即上述代码的 fn)中 executionAsyncId()
获取到执行当前回调的异步资源的 asyncId
,从调用链上追查到调用的源头。
同时,我们也需要注意到一点,init 是异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次,这会在实际使用的时候带来什么问题呢?
出于异常排查和数据分析的目的,希望在我们 Ada 架构的 Node.js 服务中,将服务器收到的由客户端发来请求的请求头中的 request-id 自动添加到发往中后台服务的每个请求的请求头中。
简单设计如下:
示例代码如下:
const http = require('http')
const { createHook, executionAsyncId } = require('async_hooks')
const fs = require('fs')
// 追踪调用链并创建调用链存储对象
const cache = {}
const hook = createHook({
init (asyncId, type, triggerAsyncId, resource) {
if (type === 'TickObject') return
// 由于在 Node.js 中 console.log 也是异步行为,会导致触发 init 钩子,所以我们只能通过同步方法记录日志
fs.appendFileSync('log.out', `init ${type}(${asyncId}: trigger: ${triggerAsyncId})\n`);
// 判断调用链存储对象是否已经初始化
if (!cache[triggerAsyncId]) {
cache[triggerAsyncId] = {}
}
// 将父节点的存储与当前异步资源通过引用共享
cache[asyncId] = cache[triggerAsyncId]
}
})
hook.enable()
// 改写 http
const httpRequest = http.request
http.request = (options, callback) => {
const client = httpRequest(options, callback)
// 获取当前请求所属异步资源对应存储的 request-id 写入 header
const requestId = cache[executionAsyncId()].requestId
console.log('cache', cache[executionAsyncId()])
client.setHeader('request-id', requestId)
return client
}
function timeout () {
return new Promise((resolve, reject) => {
setTimeout(resolve, Math.random() * 1000)
})
}
// 创建服务
http
.createServer(async (req, res) => {
// 获取当前请求的 request-id 写入存储
cache[executionAsyncId()].requestId = req.headers['request-id']
// 模拟一些其他耗时操作
await timeout()
// 发送一个请求
http.request('http://www.baidu.com', (res) => {})
res.write('hello\n')
res.end()
})
.listen(3000)
执行代码并发送一次测试,发现已经可以正确获取到 request-id
。
同时,我们也需要注意到一点,init 是异步资源创建的钩子,不是异步回调函数创建的钩子,只会在异步资源创建的时候执行一次。
但是上面的代码是有问题的,像前面介绍 async_hooks
模块时的代码演示的那样,可以使用一个异步资源不断的执行不同的函数,即异步资源有复用的可能,特别是对类似于 TCP 这种由 C/C++ 部分创建的异步资源,即多次请求可能会使用同一个 TCP 异步资源,从而使得这种情况下多次请求 init 钩子函数只会执行一次,导致多次请求的调用链追踪会追踪到同一个 triggerAsyncId。
我们将上面的代码做如下修改,来进行一次验证。
存储初始化部分将 triggerAsyncId
保存下来,方便观察异步调用的追踪关系:
if (!cache[triggerAsyncId]) {
cache[triggerAsyncId] = {
id: triggerAsyncId
}
}
timeout 函数改为先进行一次长耗时再进行一次短耗时操作:
function timeout () {
return new Promise((resolve, reject) => {
setTimeout(resolve, [1000, 5000].pop())
})
}
重启服务后,使用 postman (不用 curl 是因为 curl 每次请求结束会关闭连接,导致不能复现)连续的发送两次请求,可以观察到以下输出:
{ id: 1, requestId: '第二次请求的id' }
{ id: 1, requestId: '第二次请求的id' }
发现在多并发且写读存储的操作间有耗时不固定的其他操作情况下,先到达服务器的请求存储的值会被后到达服务器的请求复写掉,使得前者获取到错误的值。当然,你可以保证在写和读之间不插入其他的耗时操作,但在复杂的服务中这种保障方式明显是不可靠的。这时,我们就需要使每次读写前,JS 都能进入一个全新的异步资源上下文,即获得一个全新的 asyncId。需要我们将调用链存储的部分做以下几方面修改:
const http = require('http')
const { createHook, executionAsyncId } = require('async_hooks')
const fs = require('fs')
const cache = {}
const httpRequest = http.request
http.request = (options, callback) => {
const client = httpRequest(options, callback)
const requestId = cache[executionAsyncId()].requestId
console.log('cache', cache[executionAsyncId()])
client.setHeader('request-id', requestId)
return client
}
// 将存储的初始化提取为一个独立的方法
async function cacheInit (callback) {
// 利用 await 操作使得 await 后的代码进入一个全新的异步上下文
await Promise.resolve()
cache[executionAsyncId()] = {}
// 使用 callback 执行的方式,使得后续操作都会追踪到这个新的异步上下文
return callback()
}
const hook = createHook({
init (asyncId, type, triggerAsyncId, resource) {
if (!cache[triggerAsyncId]) {
// init hook 不再进行初始化
return fs.appendFileSync('log.out', `未使用 cacheInit 方法进行初始化`)
}
cache[asyncId] = cache[triggerAsyncId]
}
})
hook.enable()
function timeout () {
return new Promise((resolve, reject) => {
setTimeout(resolve, [1000, 5000].pop())
})
}
http
.createServer(async (req, res) => {
// 将后续操作作为 callback 传入 cacheInit
await cacheInit(async function fn() {
cache[executionAsyncId()].requestId = req.headers['request-id']
await timeout()
http.request('http://www.baidu.com', (res) => {})
res.write('hello\n')
res.end()
})
})
.listen(3000)
值的一提的是,这种使用 callback 的方式与 koajs 的中间件非常的契合。
async function middleware (ctx, next) {
await Promise.resolve()
cache[executionAsyncId()] = {}
return next()
}
这种使用 await Promise.resolve()
创建全新异步上下文的方式看起来总有些 “歪门邪道” 的感觉。好在 NodeJs v9.x.x 版本中提供了创建异步上下文的官方实现方式 asyncResource.runInAsyncScope
。且更好的是,在 NodeJs v14.x.x 直接提供了异步调用链数据存储的官方实现,它可以直接帮你完成调用关系追踪和数据管理的工作!!API 就不再详细介绍,我们直接改造我们之前的实现。
const { AsyncLocalStorage } = require('async_hooks')
// 直接创建一个 asyncLocalStorage 存储实例,不再需要管理 async 生命周期钩子
const asyncLocalStorage = new AsyncLocalStorage()
const storage = {
enable (callback) {
// 使用 run 方法创建全新的存储,且需要让后续操作作为 run 方法的回调执行,以使用全新的异步资源上下文
asyncLocalStorage.run({}, callback)
},
get (key) {
return asyncLocalStorage.getStore()[key]
},
set (key, value) {
asyncLocalStorage.getStore()[key] = value
}
}
// 改写 http
const httpRequest = http.request
http.request = (options, callback) => {
const client = httpRequest(options, callback)
// 获取异步资源存储的 request-id 写入 header
client.setHeader('request-id', storage.get('requestId'))
return client
}
// 使用
http
.createServer((req, res) => {
storage.enable(async function () {
// 获取当前请求的 request-id 写入存储
storage.set('requestId', req.headers['request-id'])
http.request('http://www.baidu.com', (res) => {})
res.write('hello\n')
res.end()
})
})
.listen(3000)
可以看到,官方设计的 asyncLocalStorage.run
API和我们之前的修改在模式上也很契合。
于是,在 Node.js v14.x.x 版本下,使用 async_hooks 模块进行请求追踪的功能很轻易的就实现了。
Sentry 为一套开源的应用监控和错误追踪的解决方案。这套解决方案由对应各种语言的 SDK 和一套庞大的数据后台服务组成。应用需要通过与之绑定的 token 接入 Sentry SDK 完成数据上报的配置。通过 Sentry SDK 的配置,还可以上报错误关联的版本信息、发布环境。同时 Sentry SDK 会自动捕捉异常发生前的相关操作,便于后续异常追踪。异常数据上报到数据服务之后,会通过过滤、关键信息提取、归纳展示在数据后台的 Web 界面中。
在完成接入后我们就可以从管理系统中实时查看应用的异常,从而主动监控应用在客户端的运行情况。通过配置报警、分析异常发生趋势更主动的将异常扼杀在萌芽状态,影响更少的用户。通过异常详情分析、异常操作追踪,避免对客户端应用异常两眼一抹黑的状态,更高效的解决问题。
这篇文章也将会从一键部署服务开始,通过解决部署过程中遇到的问题,分享到完成前端应用监控和异常数据使用的整个详细过程,希望会对你的部署和使用中遇到的问题有所帮助。
Sentry 的管理后台是基于 Python Django 开发的。这个管理后台由背后的 Postgres 数据库(管理后台默认的数据库,后续会以 Postgres 代指管理后台数据库并进行分享)、ClickHouse(存数据特征的数据库)、relay、kafka、redis 等一些基础服务或由 Sentry 官方维护的总共 23 个服务支撑运行。可见的是,如果独立的部署和维护这 23 个服务将是异常复杂和困难的。幸运的是,官方提供了基于 docker 镜像的一键部署实现 getsentry/onpremise。
这种部署方式依赖于 Docker 19.03.6+ 和 Compose 1.24.1+
Docker 是可以用来构建和容器化应用的开源容器化技术。
Compose 是用于配置和运行多 Docker 应用的工具,可以通过一个配置文件配置应用的所有服务,并一键创建和运行这些服务。
在准备好 linux 服务器之后,并按照官方文档安装好对应版本的 Docker 和 Compose 之后,将 onpremise 的源代码克隆到工作台目录:
git clone https://github.com/getsentry/onpremise.git
# 切换到 20.10.1 版本,后续的分享将会基于这个版本进行
git checkout release/20.10.1
在后续部署的过程中,需要拉取大量镜像,官方源拉取较慢,可以修改 docker 镜像源,修改或生成 /etc/docker/daemon.json
文件:
{
"registry-mirrors": ["镜像地址"]
}
然后重新加载配置,并重启 docker 服务:
sudo systemctl daemon-reload
sudo systemctl restart docker
在 onpremise 的根路径下有一个 install.sh 文件,只需要执行此脚本即可完成快速部署,脚本运行的过程中,大致会经历以下步骤:
在执行结束后,会提示创建完毕,运行 docker-compose up -d
启动服务。
在使用不添加 -d
参数运行 docker-compose up
命令后,我们可以看到服务的启动日志,需要等待内部 web、relay、snuba、kafka 等全部启动并联动初始化后,服务才算完全启动,此刻才可以使用默认端口访问管理端默认服务地址,此时可以进行域名配置,并将 80 端口解析到服务的默认端口上,便可以使用域名进行访问。
第一次访问管理后台,可以看到欢迎页面,完成必填项的配置,即可正式访问管理后台。
/api/[id]/store/
配置到公网环境,保证数据不会泄密)。完成这部分工作后,对服务没有定制化需求的可以跳至前端接入和使用部分。
可以看到在服务运行的过程中,会在 docker volume 数据卷挂载位置存储数据,如 Postgres、运行日志等,docker volume 默认挂载在 /var 目录下,如果你的 /var 目录容量较小,随着服务的运行会很快占满,需要对 docker volume 挂载目录进行修改。
# 在容量最大的目录下创建文件夹
mkdir -p /data/var/lib/
# 停止 docker 服务
systemctl stop docker
# 将 docker 的默认数据复制到新路径下,删除旧数据并创建软连接,即使得存储实际占用磁盘为新路径
/bin/cp -a /var/lib/docker /data/var/lib/docker && rm -rf /var/lib/docker && ln -s /data/var/lib/docker /var/lib/docker
# 重启 docker 服务
systemctl start docker
一键部署的 Sentry 服务总会有不符合我们使用和维护设计的地方,这个时候,就需要通过对部署配置的修改来满足自己的需求。
在通过 docker-compose 快速部署之后,我们先来观察下启动了哪些服务,并为后续的适配和修改分析下这些服务的作用,运行 docker 查看所有容器的命令:
docker ps
可以看到现在启动的所有服务,并且一些服务是使用的同一个镜像通过不同的启动参数启动的,按照镜像区分并且通过笔者的研究推测,各个服务的作用如下:
同时,根据异常上报到服务后,日志的记录情况可知,运行机制大概如下:
要对部署和运行进行修改的话,需要找到对应的配置文件,先看下 onpremise 部署实现的主要文件结构和作用:
同时需要注意的是,一旦部署过之后,install.sh 脚本就会根据 xx.example.xx 生成实际生效的文件,而且,再次执行 install.sh 脚本时会检测这些文件存不存在,存在则不会再次生成,所以需要修改配置后重新部署的情况下,我们最好将生成的文件删除,在 xx.example.xx 文件中修改配置。
根据服务组成和运行机制得知,主服务是基于 sentry-onpremise-local
镜像启动的,而 sentry-onpremise-local
镜像中的 sentry 配置会合并 sentry.conf.py
,此文件又是由 sentry.conf.example.py
生成,所以后续定制化服务时,会重点修改 sentry.conf.example.py
配置模板文件。
在数据库单机化部署的情况下,一旦出现机器故障,数据会损坏丢失,而 onpremise 的一键部署就是以 docker 的形式单机运行的数据库服务,且数据库数据也存储在本地。
可以看到 Sentry 的数据库有两个,Postgres 和 ClickHouse。
虽然 Sentry 不是业务应用,在宕机后不影响业务正常运行,数据的稳定并不是特别重要,但是 Postgres 中存储了接入 Sentry 的业务应用的 id 和 token 与对应关系,在这些数据丢失后,业务应用必须要修改代码以修改 token 重新上线。为了避免这种影响,且公司有现成的可容灾和定期备份的 Postgres 数据库,所以将数据库切换为外部数据库。
修改 sentry.conf.example.py
文件中 DATABASES
变量即可:
DATABASES = {
'default': {
'ENGINE': 'sentry.db.postgres',
'NAME': '数据库名',
'USER': '数据库用户名',
'PASSWORD': '数据库密码',
'HOST': '数据库域名',
'PORT': '数据库端口号',
}
}
由于不再需要以 Docker 启动 Postgres 数据库服务,所以将 Postgres 相关信息从 docker-compose.yml 文件中删除。删掉其中的 Postgres 相关配置即可。
depends_on:
- redis
- postgres # 删除
# ...
services:
# ...
# 删除开始
postgres:
<< : *restart_policy
image: 'postgres:9.6'
environment:
POSTGRES_HOST_AUTH_METHOD: 'trust'
volumes:
- 'sentry-postgres:/var/lib/postgresql/data'
# 删除结束
# ...
volumes:
sentry-data:
external: true
sentry-postgres: # 删除
external: true # 删除
同时,由于 Sentry 在启动前,初始化数据库结构的使用会 pg/citext 扩展,创建函数,所以对数据库的用户权限有一定要求,也需要将扩展提前启用,否则会导致 install.sh 执行失败。
随着数据的上报,服务器本地的磁盘占用和数据库大小会越来越大,在接入300万/日的流量后,磁盘总占用每天约增加 1.4G-2G,按照 Sentry 定时数据任务的配置保留 90 天来说,全量接入后磁盘占用会维持在一个比较大的值,同时这么大的数据量对数据的查询也是一个负担。为了减轻负担,需要从服务端和业务应用端同时入手。综合考虑我们将数据保留时长改为 7 天。修改 .env
文件即可:
SENTRY_EVENT_RETENTION_DAYS=7
也可以直接修改 sentry.conf.example.py
:
SENTRY_OPTIONS["system.event-retention-days"] = int(
env("SENTRY_EVENT_RETENTION_DAYS", "90")
)
# 改为
SENTRY_OPTIONS["system.event-retention-days"] = 7
需要注意的是,定时任务使用 delete 语句删除过期数据,此时磁盘空间不会被释放,如果数据库没有定时回收的机制,则需要手动进行物理删除。
# 作为参考的回收语句
vacuumdb -U [用户名] -d [数据库名] -v -f --analyze
Sentry 本身支持 SAML2、Auth0 等单点登录方式,但是我们需要支持 CAS3.0,Sentry 和 Django 没有对此有良好支持的插件,所以笔者组装了一个基本可用的插件 sentry_cas_ng。
使用时,需要进行插件的安装、注册和配置,插件使用 github 地址安装,需要一些前置的命令行工具,就不在 requirements.txt 文件中进行配置,直接修改 sentry/Dockerfile
文件进行安装,追加以下内容:
# 设置镜像源加速
RUN echo 'deb http://mirrors.aliyun.com/debian/ buster main non-free contrib \n\
deb http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib \n\
deb http://mirrors.aliyun.com/debian/ buster-backports main non-free contrib \n\
deb http://mirrors.aliyun.com/debian-security/ buster/updates main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster-updates main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian/ buster-backports main non-free contrib \n\
deb-src http://mirrors.aliyun.com/debian-security/ buster/updates main non-free contrib' > /etc/apt/sources.list
# 升级和安装前置工具
RUN apt-get update && apt-get -y build-dep gcc \
&& apt-get install -y -q libxslt1-dev libxml2-dev libpq-dev libldap2-dev libsasl2-dev libssl-dev sysvinit-utils procps
RUN apt-get install -y git
# 安装这个基本可用的 cas 登录插件
RUN pip install git+https://github.com/toBeTheLight/sentry_cas_ng.git
同时修改 sentry.conf.example.py
文件,以进行插件的注册和配置项配置:
# 修改 session 库,解决 session 较长的问题
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# 在 django 中安装插件
INSTALLED_APPS = INSTALLED_APPS + (
'sentry_cas_ng',
)
# 注册插件中间件
MIDDLEWARE_CLASSES = MIDDLEWARE_CLASSES + (
'sentry_cas_ng.middleware.CASMiddleware',
)
# 注册插件数据管理端
AUTHENTICATION_BACKENDS = (
'sentry_cas_ng.backends.CASBackend',
) + AUTHENTICATION_BACKENDS
# 配置 CAS3.0 单点登录的登录地址
CAS_SERVER_URL = 'https://xxx.xxx.com/cas/'
# 配置 cas 版本信息
CAS_VERSION = '3'
# 因为插件是使用拦截登录页强制跳转至 SSO 页面的方式实现的
# 所以需要配置登录拦截做跳转 SSO 登录操作
# 需要将 pathReg 配置为你的项目的登录 url 的正则
# 同时,当页面带有 ?admin=true 参数时,不跳转至 SSO
def CAS_LOGIN_REQUEST_JUDGE(request):
import re
pathReg = r'.*/auth/login/.*'
return not request.GET.get('admin', None) and re.match(pathReg, request.path) is not None
# 配置登出拦截做登出操作
# 让插件识别当前为登出操作,销毁当前用户 session
# 为固定内容,不变
def CAS_LOGOUT_REQUEST_JUDGE(request):
import re
pathReg = r'.*/api/0/auth/.*'
return re.match(pathReg, request.path) is not None and request.method == 'DELETE'
# 是否自动关联 sso cas 信息至 sentry 用户
CAS_APPLY_ATTRIBUTES_TO_USER = True
# 登录后分配的默认组织名称,必须与管理端 UI 设置的组织名相同
AUTH_CAS_DEFAULT_SENTRY_ORGANIZATION = '[组织名]'
# 登录后默认的角色权限
AUTH_CAS_SENTRY_ORGANIZATION_ROLE_TYPE = 'member'
# 登录后默认的用户邮箱后缀,如 @163.com 中的 163.com
AUTH_CAS_DEFAULT_EMAIL_DOMAIN = '[邮箱后缀]'
完成配置后,需要使用 Sentry 的默认组织名 sentry,访问 xxx/auth/login/sentry?admin=true
,避过 CAS 插件拦截,以管理员身份登录,然后修改 Sentry 设置的组织名为插件中的配置的组织名变量 AUTH_CAS_DEFAULT_SENTRY_ORGANIZATION
的值。否则新用户通过 SSO 登录后会由于要分配的组织名和服务设置的组织名不匹配出现错误。
在登录 Sentry 之后,可以发现异常的时间为 UTC 时间,每个用户都可以在设置中将时区改为本地时区:
出于用户友好考虑,可以直接修改服务的默认时区,在 sentry.conf.example.py
文件中添加配置:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
SENTRY_DEFAULT_TIME_ZONE = 'Asia/Shanghai'
Sentry 会获取请求头中 X-Forwarded-For (结构为ip1,ip2,ip3)的第一个 IP 为真实用户 IP,Sentry 一键部署启动的服务的最前端是一个 Nginx 服务器,它的配置就是之前提到的 nginx/nginx.conf
文件,在其中可以看到一行 proxy_set_header X-Forwarded-For $remote_addr;
,$remote_addr
是“客户端” IP,但是这个客户端是相对于 Nginx 服务的而言的,如果前面有代理服务器,那么拿到的就是代理服务器的 IP。在我们的部署环境中,X-Forwarded-For 由前置的 Nginx 服务提供,且已经处理成需要的格式,所以删除此行即可。
在 Sentry 的默认的角色权限系统中有以下名词,在信息结构按照包含关系有组织、团队、项目、事件。
在角色层面又具有:
且角色是跟随账号的,也就是说,一个 admin 会在他加入的所有的团队中都是 admin。
在我们的权限设计中,希望的是由 owner 创建团队和团队下的项目,然后给团队分配 admin。即 admin 角色管理团队下的权限配置,但是不能创建和删除团队和项目。在 Sentry 的现状下,最接近这套权限设计的情况中,只能取消 admin 对团队、项目的增删权限,而无法设置他只拥有某个团队的权限。
在 Sentry 的配置中是这么管理权限的:
SENTRY_ROLES = (
# 其他角色
# ...
{
'id': 'admin',
'name': 'Admin',
'desc': '省略'
'of.',
'scopes': set(
[
"org:read","org:integrations",
"team:read","team:write","team:admin",
"project:read", "project:write","project:admin","project:releases",
"member:read",
"event:read", "event:write","event:admin",
]),
}
)
其中 read、write 为配置读写,admin 则是增删,我们只需要删掉 "team:admin"
和 "project:admin"
后在 sentry.conf.example.py
文件中复写 SENTRY_ROLES
变量即可。需要调整其他角色权限可以自行调整。
至此,我们的定制化配置就完成了。
基本上所有的配置都可以通过在 sentry.conf.example.py
文件中重新赋值整个变量或某个字段的方式调整,有哪些配置项的话可以去源代码的 src/sentry/conf/server.py 文件中查询,有其他需求的话可以自行尝试修改。
后续的接入使用,我们以 Vue 项目示范。
首先需要进行对应团队和项目的创建:
选取平台语言等信息后,可以创建团队和项目:
npm i @sentry/browser @sentry/integrations
其中 @sentry/browser
为浏览器端的接入 sdk,需要注意的是,它只支持 ie11 及以上版本的浏览器的错误上报,低版本需要使用 raven.js
,我们就不再介绍。
@sentry/integrations
包里是官方提供的针对前端各个框架的功能增强,后续会介绍。
在进行接入是,我们必须要知道的是和你当前项目绑定的 DSN(客户端秘钥),可在管理端由 Settings 进入具体项目的配置中查看。
import * as Sentry from '@sentry/browser'
import { Vue as VueIntegration } from '@sentry/integrations'
import Vue from 'vue'
Sentry.init({
// 高访问量应用可以控制上报百分比
tracesSampleRate: 0.3,
// 不同的环境上报到不同的 environment 分类
environment: process.env.ENVIRONMENT,
// 当前项目的 dsn 配置
dsn: 'https://[clientKey]@sentry.xxx.com/[id]',
// 追踪 vue 错误,上报 props,保留控制台错误输出
integrations: [new VueIntegration({ Vue, attachProps: true, logErrors: true })]
})
可以看到的是 VueIntegration 增强上报了 Vue 组件的 props,同时我们还可以额外上报构建的版本信息 release。此时,Sentry 已经开始上报 console.error、ajax error、uncatch promise 等信息。同时,我们还可以进行主动上报、关联用户。
Sentry.captureException(err)
Sentry.setUser({ id: user.id })
Sentry 还提供了基于 Webpack 的 plugin:webpack-sentry-plugin 帮助完成接入,就不再做介绍。
进入某个具体的项目后,可以看到 Sentry 根据错误的 message、stack、发生位置进行归纳分类后的 Issue 列表:
在右侧,可以看到每个错误的发生趋势、发生次数、影响用户数和指派给谁解决这个问题的按钮。我们可以通过这些指标进行错误处理的优先级分配和指派。
通过发展趋势,我们也可以观察到是否与某次上线有关,还可以通过左侧的 Discover 创建自定义的趋势看板,更有针对性的进行观察。
点击进入每个 issue 后,可以看到详细信息:
从上到下,可以看到错误的名称,发生的主要环境信息,Sentry 提取的错误特征,错误堆栈,在最下面的 BREADCRUMBS
中可以看到异常发生前的前置操作有哪些,可以帮助你进行问题操作步骤的还原,协助进行问题排查。
Sentry 的入门使用到此为止。其他的功能,如报警配置、性能监控可以自行探索。
一个自卑的年轻人求解的故事。以书中的话将书中的故事简要的讲了一遍。
常见的瀑布流实现大部分只适用于子块尺寸固定或内部有图片异步加载的情况。
而对于子块有图片这种可能引起尺寸变化的情况,通常的做法是写死图片高度,或检测内部的 img 元素从而在 onload 事件中进行重排。
由于我们业务中尺寸变化情况更为复杂,如于子块本身异步初始化、内部数据异步获取,且这种尺寸变化时机不可确定,为满足这种需求所以调研完成了一个通用万能的瀑布流实现。
以下代码部分以 Vue 为例,思路和机制是通用的。
使用“神奇”一词是因为 promsie 的链式调用中有很多默认的 handler 和值的隐含传递。
可以作为设计模式的理论基础阅读,同套思想在不同层面的最佳实践。 架构解决的不是代码本身的问题,而是人力资源的问题。
架构和设计没有任何区别,并非高层级的才是架构,而底层的实现细节就不是。
架构的终极目标是用最小的人力成本来满足构建和维护该系统的需求。
一个软件架构的优劣,可以用它满足用户需求所需要的成本来衡量。如果成本很低,并且在系统的整个生命周期内一直都能维持这样的低成本,那么这个系统的设计就是优良的。如果该系统的每次发布都会提升下一次变更的成本,那么这个设计就是不好的。
对于每个软件系统,我们都可以通过行为和架构两个维度来体现它的实际价值。
系统行为是紧急的;系统架构是重要的。
详略
详略
软件架构的工作实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间相互通信的方式。
良好的软件架构可以让系统便于理解、易于修改、方便维护,并且能够轻松部署。终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。
如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间的保留尽可能多的可选项(可能性)。