项目中在使用 sentry 上传事件的 attachment 函数过程中发现,附带的 log 文件是未压缩的,于是有了需求,即需要在 sentry 内部将未压缩的文件流压缩后再上传给服务器
这个需求看似挺简单的,其实过程挺坎坷的,因为要看 sentry 的源码,并对 zlib 的库有一定的了解才行。
这个文章为了以后同样有此需求的人作参考。
sentry 分为 sentry 源码和 crashpad 源码两部分,日常使用中,我们可以利用 sentry 自带的崩溃自动上传机制查看崩溃时的堆栈,不过堆栈只能看见崩溃的地方(局部的几个函数),并不能知晓
用户上下文的行为,复现一个崩溃要了解用户是以什么样的“动作”触发的,所以在崩溃时能够看到调试日志,对于开发人员来说是很重要的,不过在阅读 sentry 源码发现,sentry 附加的文件是从本地读取的,
也就是说 sentry 内部拿到本地文件的路径,传给 fopen 和 fread,并且内部没有集成压缩的 api,需要我们修改源码来实现压缩。
在不大面积修改 sentry 源码的前提下,我打算在本地项目中把日志先压缩到 buffer 内,再把 buffer 传给 sentry,这样 sentry 只需要一个接受 buffer 参数的接口即可,正好 sentry 内的 sentry_attachment_s 的结构体有 buf 参数,
struct sentry_attachment_s {
sentry_path_t *path;
sentry_attachment_t *next;
char *buf;
size_t buf_len;
};
这样我们可以手动创建一个接受 buffer 的接口,比如
static void
add_attachment_buf(sentry_options_t *opts, const char *buf, size_t buf_len)
{
if (!buf) {
return;
}
sentry_attachment_t *attachment = SENTRY_MAKE(sentry_attachment_t);
if (!attachment) {
sentry__path_free(path);
return;
}
attachment->buf = buf;
attachment->buf_len = buf_len;
attachment->next = opts->attachments;
opts->attachments = attachment;
}
#ifdef SENTRY_PLATFORM_WINDOWS
void
sentry_options_add_attachment_buf(
sentry_options_t *opts, const char *buf, size_t buf_len)
{
add_attachment_buf(opts, buf, buf_len);
}
说明一下,sentry 内部是链表读取文件路径的,可以使用这个接口替换已有接受文件路径的接口调用
之后,我们还需要提前压缩文件,即输入未压缩的文件数据,输出压缩后的文件数据,这里我们可以使用 zlib 库,如下
int CompressToGzip(const char* input, int inputSize, char* output) {
z_stream zs;
zs.zalloc = Z_NULL;
zs.zfree = Z_NULL;
zs.opaque = Z_NULL;
zs.avail_in = (uInt)inputSize;
zs.next_in = (Bytef*)input;
// zs.avail_out = (uInt)outputSize;
zs.next_out = (Bytef*)output;
// hard to believe they don't have a macro for gzip encoding, "Add 16" is the
// best thing zlib can do: "Add 16 to windowBits to write a simple gzip header
// and trailer around the compressed data instead of a zlib wrapper"
deflateInit2(&zs, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 | 16, 8,
Z_DEFAULT_STRATEGY);
deflate(&zs, Z_FINISH);
deflateEnd(&zs);
return zs.total_out;
}
这个函数接受未压缩文件的输入和大小,输出压缩后的数据,返回压缩后的大小,压缩格式是 .gz
调用的话就很简单了,
void Send(std::string log, int severity, sentry_extra_data extra_data) {
int input_size;
const char* input = file_size(L"D:\\project\\bin\\Debug\\debug.log", &input_size);
int output_size = input_size;
char* output = new char[input_size];
int size = CompressToGzip(input, input_size, output);
sentry_options_add_attachment_buf(options, output, size);
delete output;
if (!extra_data.extra_data_key.empty()) {
sentry_set_extra(extra_data.extra_data_key.c_str(),
extra_data.extra_data_value);
}
auto event = sentry_value_new_message_event(sentry_level_e(severity), nullptr,
log.c_str());
sentry_capture_event(event);
}
未压缩文件的输入和大小的函数如下,
char* file_size(const wchar_t* filename, int* filesize) {
FILE* fp = _wfopen(filename, L"rb");
fseek(fp, 0L, SEEK_END);
*filesize = ftell(fp);
file_buffer = new char[*filesize];
rewind(fp);
fread(file_buffer, 1, *filesize, fp);
fclose(fp);
return file_buffer;
}
另外我们还需要在 sentry 内部修改上传日志的文件名,可以参考下面这个函数,不使用 path 参数,而是手动赋值一个文件名给 *s
const sentry_pathchar_t *
sentry__path_filename(const sentry_path_t *path)
{
const wchar_t *s = L"debug.log.gz";
const wchar_t *ptr = s;
size_t idx = wcslen(s);
while (true) {
if (s[idx] == L'/' || s[idx] == L'\\') {
ptr = s + idx + 1;
break;
}
if (idx > 0) {
idx -= 1;
} else {
break;
}
}
return ptr;
}
这些步骤都完成后,上传日志给 sentry 后应该就可以看到压缩的文件了
在这个需求进行到这边时,我以为差不多要完成了,发现压缩日志只在正常上传日志的事件中出现,而在崩溃时,sentry 自动上传的崩溃事件中并没有附加压缩文件,嗯,果然没那么简单
重新梳理了 sentry 源码,了解了 crash 的机制,并结合使用 sentry 过程时,需要一个 crashpad_handler.exe 程序才能完成崩溃自动上传事件,差不多摸透崩溃自动上传的工作原理。
CreateProcess 可以在父进程中打开子进程,is_embedded_in_dll 是判断是否有 crashpad_handler.exe,没有的话就 rundll32.exe 代替,并和平结束进程,有的话,就使用 command_line 参数,command_line 是个长字符串,
类型为 wstring,里面包括了 sentry 上报日志所需要的各种变量,比如 dsn,user_key,attachments 文件路径等等
crashpad_handler.exe 子进程就是程序崩溃后执行的,故要压缩其中的日志文件也要在 crashpad 代码模块中进行,如下,
CopyFileContent 即是读取本地文件以及写入 sentry 日志的操作,我们不需要改动它,而是新写一个 CopyCompressFileContent 函数,这个函数只需要筛选出对应的日志名字,再压缩它
注意添加头文件
#include "third_party/zlib/zlib/zlib.h"
void CopyCompressFileContent(FileReaderInterface* file_reader,
FileWriterInterface* file_writer) {
char buf[4096];
char output[4096];
FileOperationResult read_result;
z_stream zs;
zs.zalloc = Z_NULL;
zs.zfree = Z_NULL;
zs.opaque = Z_NULL;
do {
read_result = file_reader->Read(buf, sizeof(buf));
if (read_result < 0) {
break;
}
zs.avail_in = (uInt)read_result;
zs.next_in = (Bytef*)buf;
zs.avail_out = (uInt)sizeof(output);
zs.next_out = (Bytef*)output;
deflateInit2(
&zs, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 | 16, 8, Z_DEFAULT_STRATEGY);
deflate(&zs, Z_FINISH);
deflateEnd(&zs);
if (read_result > 0 && !file_writer->Write(output, zs.total_out)) {
break;
}
} while (read_result > 0);
}
有了压缩之后,同时把生成的最终文件名字改成 .gz,比如,
在程序崩溃后,我们在 sentry 不仅能够看到崩溃堆栈,也能查看调试日志以复现问题并解决掉
一些注意的地方,zlib 库只能用于 .gz 的解压缩,不能用于 zip 的解压缩
参考:https://www.zlib.net/zlib_faq.html#faq11
在对 z_stream 赋值时,一定要确保 avail_in 和 avail_out 都有足够的大小,不然 deflate 容易出现 Z_BUF_ERROR 错误,这个错误只是个提示,并不是致命的,它告诉我们需要足够大的缓冲区才行
参考:zlib Z_BUF_ERROR with specific file and specific buffer sizes
PS: 在一开始我将 zs.avail_out 注释了,导致我花了很久才找到错误的地方(注释的原因是网上某篇教程提供的例子中这样写的,没多想)
并且我习惯新创建一个工程来测试例子的可靠性,如果没问题我再集成到项目中,在注释 zs.avail_out 的工程中,可以正常压缩文件,这使我在很长一段时间内忽略 avail_out 的赋值,而在 crashpad 工程中集成压缩代码时,
zs.total_out 会一直返回 0,导致压缩文件失败。并且 deflate 报 Z_BUF_ERROR,在对 zs.avail_out 赋值后,压缩成功,至于为何在不同项目中会成功或失败,我们要在后面花时间去研究它。
如果想要压缩为 zip 文件,则需要使用其他库,比如 minizip 库,参考:
嗯,就写到这,后续想到更多的细节会继续补充,希望能帮助到有类似需求的人