司开星的博客

Sentry 源码开发笔记

Sentry 基础

自建 Sentry 服务器步骤

Sentry 是一套运行在服务器上的日志系统,自行搭建 Sentry 服务的流程为(以 Python 环境为例,Docker 环境可参考官方文档):

  1. 本机安装 Sentry 依赖的两个数据库:RedisPostgreSQL
  2. 两个数据库的初始化及运行;
  3. 新建 Python 虚拟环境(本文使用的 Python 均运行在虚拟环境中);
  4. pip install sentry
  5. Sentry 初始化配置:sentry init /etc/sentry(路径可自行定义,Sentry 运行时默认搜索的路径为 ~/.sentry
  6. 根据需求修改配置文件(主要是两个数据库的配置);
  7. 数据库 Sentry 相关表初始化(建db及表);
  8. 通过 sentry 命令运行三个组件:web, worker, cron

搭建好之后访问 Sentry 网页,新建 Project,页面上会提示如何在Python中给 Sentry发日志(主要使用Sentry官方提供的 Raven 包)。

具体细节本文不再赘述,数据库安装和初始化可参考相关平台文档,Sentry相关步骤可参考官方文档

Sentry 概念及结构

Sentry 整个框架有以下几个组成部分:

  • 服务器端 Sentry程序,又分为以下三个部分:
    • Web:运行 Web server,收/发 http 请求。此部分使用了 Django + uWSGI
    • Worker: 使用 Celery 异步处理一些任务(如数据库读写);
    • Cron: 运行定期任务。
  • SDK : 各语言向 Sentry 发送日志的接口组件,Python 中是 Raven 包。
  • API :除了 Web 页面,Sentry还提供了可以直接读写数据库中已有日志的接口。

开发环境搭建

本文搭建环境为 CentOS 7 。

运行环境

拷贝源码:

git clone https://github.com/getsentry/sentry

运行源码的环境跟自建服务器环境类似,只需要多安装一些依赖包:

pip install -r requirements-base.txt

运行入口

setup.py 可以找到运行入口:

1
2
3
4
entry_points={
'console_scripts': [
'sentry = sentry.runner:main',
]

sentry.runner.__init__.py 下的 main:

1
2
def main():
cli(prog_name=get_prog(), obj={}, max_content_width=100)

cli 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@click.group()
@click.option(
'--config',
default='',
envvar='SENTRY_CONF',
help='Path to configuration files.',
metavar='PATH'
)
@click.version_option(version=version_string)
@click.pass_context
def cli(ctx, config):
if config:
os.environ['SENTRY_CONF'] = config
os.environ.setdefault('SENTRY_CONF', '~/.sentry')

Sentry 使用 click 模块来提供终端接口。

此处我们跳过中间的一些调用逻辑,直接看特定命令调用的最终接口,比如sentry run web命令,调用的是 sentry.runner.commands.run.py 下的 web 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@run.command()
@click.option('--bind', '-b', default=None, help='Bind address.', type=Address)
@click.option(
'--workers', '-w', default=0, help='The number of worker processes for handling requests.'
)
@click.option('--upgrade', default=False, is_flag=True, help='Upgrade before starting.')
@click.option(
'--with-lock', default=False, is_flag=True, help='Use a lock if performing an upgrade.'
)
@click.option(
'--noinput', default=False, is_flag=True, help='Do not prompt the user for input of any kind.'
)
@log_options()
@configuration
def web(bind, workers, upgrade, with_lock, noinput):
"Run web service."
if upgrade:
click.echo('Performing upgrade before service startup...')
from sentry.runner import call_command
try:
call_command(
'sentry.runner.commands.upgrade.upgrade',
verbosity=0,
noinput=noinput,
lock=with_lock,
)
except click.ClickException:
if with_lock:
click.echo(
'!! Upgrade currently running from another process, skipping.',
err=True)
else:
raise

from sentry.services.http import SentryHTTPServer
with managed_bgtasks(role='web'):
SentryHTTPServer(
host=bind[0],
port=bind[1],
workers=workers,
).run()

函数在一些初始化操作之后调用 SentryHTTPServer 运行 Web Server。看下 SentryHTTPServer 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class SentryHTTPServer(Service):
name = 'http'

def __init__(
self, host=None, port=None, debug=False, workers=None, validate=True, extra_options=None
):
from django.conf import settings
from sentry import options as sentry_options
from sentry.logging import LoggingFormat

if validate:
self.validate_settings()

host = host or settings.SENTRY_WEB_HOST
port = port or settings.SENTRY_WEB_PORT

options = (settings.SENTRY_WEB_OPTIONS or {}).copy()
if extra_options is not None:
for k, v in six.iteritems(extra_options):
options[k] = v
options.setdefault('module', 'sentry.wsgi:application') # 应用接口
options.setdefault('protocol', 'http')
options.setdefault('auto-procname', True)
options.setdefault('procname-prefix-spaced', '[Sentry]')
options.setdefault('workers', 3)
options.setdefault('threads', 4)
options.setdefault('http-timeout', 30)
......

def run(self):
self.prepare_environment()
os.execvp('uwsgi', ('uwsgi', ))

run 方法调用了 uwsgi ,实际应用接口定义在 options.setdefault('module', 'sentry.wsgi:application'), 即 sentry.wsgi.application, 实际上调用的是 DjangoWSGI 接口。

接下来就是交给 Django 处理 Web 请求,并调用 urlconf 中对应的接口。urlconf 定义在sentry.web.urls下,其中 api/0/ 路径的配置指向 sentry.api.urls

源码修改

本文以给单 Event 添加删除接口为例说明如何修改源码。

已有接口源码

先看下已有的接口怎么做的。

根据 API文档,删除 issue 接口为:DELETE /api/0/issues/*{issue_id}*/ , sentry.api.urls.py 中对应的配置为:

1
2
3
4
5
url(
r'^(?:issues|groups)/(?P<issue_id>\d+)/$',
GroupDetailsEndpoint.as_view(),
name='sentry-api-0-group-details'
),

对应的类(.GroupDetailsEndpoint)方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

# sentry.api.endpoints.group_details.py

class GroupDetailsEndpoint(GroupEndpoint, EnvironmentMixin):
......
@attach_scenarios([delete_aggregate_scenario])
def delete(self, request, group):
"""
Removes an individual issue.

:pparam string issue_id: the ID of the issue to delete.
:auth: required
"""
from sentry.tasks.deletion import delete_group

# 更新 postgres 中对应表的 status字段值
updated = Group.objects.filter(
id=group.id,
).exclude(status__in=[
GroupStatus.PENDING_DELETION,
GroupStatus.DELETION_IN_PROGRESS,
]).update(status=GroupStatus.PENDING_DELETION)

if updated:
project = group.project

# GroupHash 模型对应表中的此 issue 搬到 GroupHashTombstone
# 模型对应表中
GroupHashTombstone.tombstone_groups(
project_id=project.id,
group_ids=[group.id],
)

transaction_id = uuid4().hex # 生成随机id

# 发送到 Redis 待 Work 进程主动处理
delete_group.apply_async(
kwargs={
'object_id': group.id,
'transaction_id': transaction_id,
},
countdown=3600,
)

self.create_audit_entry(
request=request,
organization_id=project.organization_id if project else None,
target_object=group.id,
transaction_id=transaction_id,
)

delete_logger.info(
'object.delete.queued',
extra={
'object_id': group.id,
'transaction_id': transaction_id,
'model': type(group).__name__,
}
)

return Response(status=202)

此方法可用文字描述为:

1
2
3
4
5
6
7
8
9

GroupDetailsEndpoint.delete(request, group) 方法:

1. 更新 sentry_groupedmessage.status = 3 表示预备删除(Group.objects.filter(id=[group.id](http://group.id),).exclude(status__in=[GroupStatus.PENDING_DELETION,GroupStatus.DELETION_IN_PROGRESS,]).update(status=GroupStatus.PENDING_DELETION)),更新成功继续后面的步骤;
2. 调用GroupHashTombstone.tombstone_groups 将此group从GroupHase表删除并加入GroupHashTombstone表;
3. 生成随机数 transaction_id = uuid4().hex;
4. 调用delete_group.apply_async将[group.id](http://group.id),transaction_id发送到redis队列,countdown=3600;
5. 生成此次删除的日志;
6. 返回202状态;

实际删除步骤并不在这里,而是把删除任务放到 Redis队列中,交由 worker 组件主动处理。

新增接口

要给单条 Event 添加类似此方法的删除接口有两个难点:

  • Event 模型对应的 sentry_messagestatus 字段;
  • Event 没有对应的 delete_event 函数。

为了尽量减少代码改动,先写一个同步方式删除 Event 的简单接口:

1
2
3
4
5
6
7
8
# `sentry.api.endpoints.project_event_details.ProjectEventDetailsEndpoint` 

class ProjectEventDetailsEndpoint(ProjectEndpoint):
......
def delete(self, request, project, event_id):
"""
Remove an Event
````````````
"""
# from sentry.tasks.deletion import delete_event
try:
    # 此处参照当前类的 get 方法获取 event 对象
    event = Event.objects.get(
        event_id=event_id,
        project_id=project.id,
    )
    # TODO: Since other api use apply_async
    # for sending task to redis, this method
    # should rewrite with apply_async.
    event.delete()  # 直接调用模型对象的 delete 方法删除
except Event.DoesNotExist:
    return Response({'detail': 'Event not found'}, status=404)

transaction_id = uuid4().hex
self.create_audit_entry(
    request=request,
    organization_id=project.organization_id if project else None,
    target_object=event.id,
    transaction_id=transaction_id,
)

delete_logger.info(
    'object.delete.queued',
    extra={
        'object_id': event.id,
        'transaction_id': transaction_id,
        'model': type(event).__name__,
    }
)
return Response(status=202)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

具体实现可参考`https://github.com/chroming/sentry`。

### 源码运行入口

运行源码不能直接用命令行的方式,否则运行的是环境中安装的版本。修改完成后可以通过 `setup.py` 将修改后的源码安装到环境中,但修改过程中使用源码运行更方便调试。
可以在项目下添加一个 `run_server.py` 运行入口:

```python
# run_server.py

from sentry.runner.commands.run import web


def main():
web.main()


if __name__ == '__main__':
main()

不过如果想在运行时打断点调试这样就不行了,因为 sentry 使用的 uWSGI 无法直接断点调试。我们可以重写入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
# run_simple_server.py

from wsgiref import simple_server
from sentry.wsgi import application


def main():
server = simple_server.make_server('', 9000, application)
server.serve_forever()


if __name__ == '__main__':
main()

这样运行的服务无法处理静态资源,也就是打不开 sentry 网页,不过调试 API 足够了。

安装到环境

开发完成后就可以把源码安装到当前环境。

基础工具:

sudo yum install gcc zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel openssl-devel tk-devel libffi-devel python-devel gcc-c++

sudo yum install npm

如果你改的是 9.0.x 分支代码,此版本的 requirements-base.txt 要做如下改动:

redis-py-cluster>=1.3.4,<1.4.0

改为:

redis-py-cluster==1.3.4

(因为最新版(1.3.5版)的 redis-py-cluster 要求 redis 模块版本不低于 1.3.6,但 sentry 要求 redis 不高于1.3.5,会造成冲突无法正常安装,出现pkg_resources.ContextualVersionConflict: (redis 2.10.5 (/home/postgres/.local/share/virtualenvs/sentry-U-p3B7ZI/lib/python2.7/site-packages), Requirement.parse('redis>=2.10.6'), set(['redis-py-cluster'])) 的报错 。)

进入源码目录:

pip install --editable .

然后就可以和平常一样使用 sentry 命令了。