
1. 为什么Sentry自带的堆栈信息解决不了实际问题接线上故障是件让人头大的事。有一次收到一条报警前端日志里只有一个孤零零的TypeError: Failed to fetch连具体哪个接口、哪段代码、用户当时的操作路径都看不到。我第一反应是检查breadcrumbs结果发现用户从进入页面到报错之间只有三四个面包屑既没有请求体也没有响应体更别说本地缓存的业务日志了。最后是靠某个用户把浏览器 console 内容手动复制发给我才对上了现场。吃了这个亏以后我把“附件上传”正式列进了团队的错误采集规范里。Sentry 的附件Attachment机制本质上是把 event 之外的各种“上下文文件”跟着一条错误事件一起发到 Sentry 后端然后在 Issue 详情页的 Attachments 区域直接查看或下载。为什么这个能力很关键因为一条标准 error event 里能携带的信息实际上是有限的message、stacktrace、tags、breadcrumbs、user 上下文就这些。它们帮你回答“哪里崩了”“调用栈长什么样”但在很多真实故障场景里光有这些是不够的。举几个我实际碰过的例子移动端崩溃堆栈指向原生代码里的一个空指针但为什么这个对象是空的需要看设备日志和用户操作路径。后端返回一个 500前端只拿到一个泛泛的network error需要把用户请求的 headers、body、response 片段一起带上来才能模拟重现。用户上传文件失败光靠堆栈看不到用户本地的文件大小、网络状态、浏览器版本这些数据往往散落在 window 全局变量、localStorage、性能指标里。崩溃发生在混合栈环境比如 WebView 里 H5 脚本异常真正的原因却在原生层日志里。这些都是所谓“元数据不够”的场景。而附件能解决的就是这个问题把日志文件、截图、录屏、网络请求报文、内存快照、配置文件等任意二进制内容和对应的事件绑定在一起形成完整现场。从协议层面讲Sentry 有两种数据上报端点一个是老的/store端点用于提交 event另一个是/envelope端点用于提交事件和附件组合体。附件在传输协议里被定义为一种特定类型的 item叫做attachment。它不是简单地塞在 event JSON 的某个字段里而是以独立的块和 event 一起放进一个 envelope 信封对象中。这一点很关键也是很多人最开始没搞明白的地方如果你只用/store端点传 event附件是永远不可能出现在 Issue 里的。官方对附件也有一套默认限制我以 Self-hosted Sentry 24.x 和 SaaS 版本为例主要的边界参数如下参数默认值说明单条事件附件数量上限100不只是文件个数也包含 file attachment 条目单附件大小上限20MB超过会被丢弃或请求被拒单条 event 总附件大小20MB多个附件加起来不得超过支持的上传接口/api/{project_id}/envelope/不支持单独上传附件再关联这套限制在实际使用中基本够用但理解了限制边界后才能知道我们后面为什么要做体积控制、动态降级这些事。2. 附件的数据边界该传什么和不传什么这里先泼一盆冷水不是所有“看起来有用的文件”都值得往 Sentry 上挂。附件虽然方便但每个附件都会占对象存储空间数据量大起来以后存储费用、检索性能、SDK 上传耗时都会跟着涨。我在团队里定了一个规矩任何要挂到 Sentry 上的附件先过一遍下面的清单。值得传的数据我按类型整理成这张表附件类型典型内容建议单文件上限保留原则日志文件console.log、业务日志、后端转发给客户端的调试日志2MB只保留最近由本次故障产生的部分不传全量日志网络请求报文失败接口的 request/response body、headers、耗时200KB建议截断 body只保留前 N 个字符崩溃现场截图/录屏移动端用户反馈截图、WebView 白屏截图5MB开启压缩JPG/WebP 优先性能快照内存占用、设备电量、网络信号强度等500KB一般以 JSON 形式写入附件配置与环境信息user-agent、路由表、构建版本、灰度开关状态100KB动态获取不要硬编码千万别传的类型我也说得很直白用户输入的明文密码、身份证号、银行卡号哪怕是日志里出现的也不行。带完整 token 或 Cookie 的请求头除非你确定数据清洗规则已经生效否则传上来就是事故。客户端本地超大文件比如用户上传的 Excel、视频原片这些不是“上下文”属于用户私有数据。跟本次错误完全无关的业务数据。我见过有人把订单列表做成附件只是因为“可能会用到”这是典型的容量浪费。有一个点特别容易被忽略Sentry 后台自带Data Scrubbers数据清洗功能可以脱敏部分 PII 数据但它对附件内容的清洗能力有限。清洗规则主要作用于 event 自带的字段比如 user.email、request.url 这些对附件里的文本内容并不会自动做同样的脱敏处理。所以发附件之前自己先清洗一遍比什么都可靠。顺便说一下体积预算的方法。假设一次错误采集要附带 1MB 日志如果每天产生 1000 条这样的错误那一天就是 1GB 增量存储一个月就是 30GB这还不算索引和冗余备份。如果把单文件上限压到 200KB每天增量就降到 200MB。这样的预算概念必须在设计阶段就传递到每个开发手里否则线上放开上传以后Sentry 的存储增长会快到让你措手不及。3. 前端接入附件上传的实操配置与坑点前端接入附件上传最直接的方式是使用 Sentry 前端 SDK 自带的 Attachment API。我以sentry/browser7.x/8.x 为例说明三种实际可用的方案。3.1 在捕获异常时手动挂附件这种方式最灵活适合在业务代码里按需添加。核心思路是在captureException之前把附件挂到当前 scope 上import * as Sentry from sentry/browser; Sentry.init({ dsn: https://your-public-keyyour-org.sentry.io/12345, environment: production, integrations: [], beforeSend(event) { // 这里不要做异步操作见下文说明 return event; }, }); function handleError(err) { const recentLogs collectRecentLogs(); // 从内存缓存里取日志 const requestPayload captureLastFailedRequest(); // 截取最近一次失败请求的摘要 Sentry.withScope((scope) { scope.addAttachment({ filename: console.log, data: recentLogs, contentType: text/plain, }); scope.addAttachment({ filename: last-request.json, data: JSON.stringify(requestPayload, null, 2), contentType: application/json, }); Sentry.captureException(err); }); }这里有个版本差异要提醒早期 SDK 用的是Sentry.addAttachment()这个全局方法后来部分版本改成通过scope.addAttachment()来挂载。如果升级 SDK 后发现全局方法没了不是功能被砍是 API 位置变了。建议在使用前快速查一下当前版本的类型定义。另外一个更隐蔽的坑是scope.addAttachment()必须在captureException之前同步调用。在浏览器端如果先把scope.addAttachment放在一个 setTimeout 或 Promise 回调里然后再执行captureException可能不是同一个 scope 上下文附件会丢失。SDK 的 scope 是 async 本地存储模型异步回调里拿到的 scope 和调用入口处的 scope 并不一定相同。3.2 在 beforeSend 里挂附件的问题很多人想在beforeSend这个钩子里把附件挂上去觉得这样所有错误都能统一附带同一份日志。方向是对的但实现上容易踩坑。beforeSend是一个可以返回 event 对象的同步钩子如果你的业务逻辑里需要先读取某个文件内容再生成附件数据就要小心了。有的 SDK 的beforeSend设计成支持 Promise但并非所有场景都支持得那么完美。我实际测过一些版本在beforeSend里await一个异步读取操作然后再return event偶尔会出现附件加载不出来的情况特别在 React Native 和部分浏览器环境下。我的建议是附件内容尽量提前准备。比如把最近一段时间的日志在内存里维护成一个环形缓冲区最多保留 200 条定时更新这样在错误发生的那一刻数据已经在内存里了直接同步挂上去不做异步操作既稳定又高效。const logBuffer []; const MAX_LOG_LENGTH 200; function pushLog(level, message) { logBuffer.push({ level, message, time: Date.now() }); if (logBuffer.length MAX_LOG_LENGTH) { logBuffer.shift(); } } window.addEventListener(error, (event) { const logs logBuffer.slice(-50).map((l) [${l.level}] ${l.message}).join(\n); Sentry.withScope((scope) { scope.addAttachment({ filename: buffer.log, data: logs, contentType: text/plain, }); Sentry.captureException(event.error || new Error(event.message)); }); });3.3 移动端和前端框架的差异移动端接入的 API 思路类似但有几个特有配置需要留意。Android 上常用new SentryAndroid.init(...)并配置attachStacktrace和attachScreenshot前者是挂线程堆栈后者是在崩溃时自动抓截图但截图需要用户在reportDialog里授权。iOS 上有个attachScreenshot和attachViewHierarchy后者会把视图层级结构以 JSON 形式作为附件上传排查 UI 布局问题很有用。Flutter 项目则推荐用sentry_flutterSDK 的Sentry.addAttachment方式加入file或bytes附件。实际使用中Flutter 端最容易出现的问题是附件在逻辑上已挂上但传到原生侧时因为路径权限问题没读出来最后在 Sentry 后台看不到附件。排查方法是先在本地把附件内容打印出来确认内存里没问题再检查原生文件访问权限。3.4 官方集成自动附加的日志类型除了手动挂附件Sentry 官方还提供了一些集成能让部分信息自动变成附件比如CaptureConsole把console.error/warn输出捕获进 breadcrumbs 或附件。HttpClient/httpClientIntegration在浏览器和 Node 环境记录网络请求的耗时和响应体片段。ReportDialog/showReportDialog面向用户的崩溃反馈弹窗用户可以主动描述问题并附上截图。这些集成适合做兜底但我不建议全部依赖。它们附加的信息维度比较固定不一定符合业务现场。最佳实践是“官方集成做兜底 业务代码补充关键现场”两者配合使用。4. 绕过SDK手动上传Envelope后端替客户端转发的完整方案有的场景下前端 SDK 无法覆盖比如 Electron 主进程、自研网络框架、或公司内部的一个中转服务需要替客户端转发错误数据。这时候就需要手动构造 Sentry Envelope 并调用/api/{project_id}/envelope/接口。4.1 Envelope 的构造原理Envelope 直译“信封”它是 Sentry 服务端接收数据的标准载体。一个 Envelope 的基本结构是{event_id:...,dsn:...,sent_at:...,sdk:{name:...,version:...}} {type:event,length:123} { event json payload } {type:attachment,length:456,filename:app.log,content_type:text/plain} 附件二进制内容第一行的信封头Envelope Header包含event_id、dsn、sent_at等基础信息。接着是每个 item 的头部每行一个 JSON里面必须包含type和length字段表示后面 payload 的类型和长度。然后跟着的就是对应类型的 payload 内容。如果还有下一个 item就继续重复“item header payload”的格式。这里有一个我刚开始手动上传时踩过的坑必须把 event 和附件放在同一个 Envelope 里一次提交不存在“先传 event 再补附件”的 API。很多自研上传脚本最开始只传了 event再单独 POST 一个 attachment结果 Sentry 后台永远看不到附件就是这个原因。4.2 用 curl 快速验证手动构造 Envelope 最直接的就是用 curl。下面这个示例展示了一个最简化但完整的请求你需要替换其中的project_id和dsncat envelope.txt EOF {event_id:a1b2c3d4e5f60718293a4b5c6d7e8f90,dsn:https://your-public-keyyour-org.sentry.io/12345,sent_at:2025-01-01T10:00:00.000Z,sdk:{name:manual-curler,version:0.0.1}} {type:event,length:146} {event_id:a1b2c3d4e5f60718293a4b5c6d7e8f90,level:error,message:manual envelope test,platform:other,timestamp:2025-01-01T10:00:00.000Z} {type:attachment,length:36,filename:app.log,content_type:text/plain} connection timeout at 2025-01-01 10:00:00 EOF curl -X POST https://your-org.sentry.io/api/12345/envelope/ \ -H Content-Type: application/x-sentry-envelope \ --data-binary envelope.txt注意几个要点event_id在信封头和 event payload 里要保持一致建议用 UUID 的十六进制形式。length必须是字节数不是字符数。如果内容是中文用wc -c看字节长度而不是直接肉眼数。Content-Type是application/x-sentry-envelope不是multipart/form-data。我自己测试时有的网关也接受 multipart但官方标准是 x-sentry-envelope为了兼容性优先用官方标准。请求成功以后一般会返回 200 或 201。如果返回 400通常是 Envelope 格式不对返回 413 则是超了体积限制返回 429 是被限流。这些状态码的含义下面会细说。4.3 Python 构造完整请求在生产环境做中转服务时用 curl 显然不够我用 Python 的requests库写过一个通用工具类核心逻辑如下import json import time import uuid import requests PROJECT_ID 12345 DSN https://your-public-keyyour-org.sentry.io/12345 def build_envelope(event_payload: dict, attachments: list): event_id event_payload.get(event_id) or uuid.uuid4().hex event_payload[event_id] event_id envelope_header { event_id: event_id, dsn: DSN, sent_at: time.strftime(%Y-%m-%dT%H:%M:%S.000Z, time.gmtime()), sdk: {name: manual-python-forwarder, version: 0.0.1}, } body json.dumps(envelope_header).encode(utf-8) b\n event_bytes json.dumps(event_payload).encode(utf-8) body json.dumps({ type: event, length: len(event_bytes), }).encode(utf-8) b\n event_bytes for attachment in attachments: content attachment[data] if isinstance(content, str): content content.encode(utf-8) header { type: attachment, length: len(content), filename: attachment[filename], } if attachment.get(content_type): header[content_type] attachment[content_type] body json.dumps(header).encode(utf-8) b\n content return event_id, body def send_envelope(event_payload: dict, attachments: list): event_id, body build_envelope(event_payload, attachments) resp requests.post( fhttps://your-org.sentry.io/api/{PROJECT_ID}/envelope/, databody, headers{Content-Type: application/x-sentry-envelope}, timeout10, ) print(event_id, resp.status_code, resp.text) return resp这段代码的思路很清晰先把 Envelope Header 序列化再依次拼接 event item 和 attachment item最终整个 body 作为POST的 data 发出去。build_envelope返回的event_id要记到日志里这样之后如果在 Sentry 后台找不到这条事件还能在服务端日志里对应排查。4.4 状态码与常见错误手动上传时最常见的状态码和处理方法状态码含义处理建议200 / 201上传成功无需处理400Envelope 格式错误比如 JSON 解析失败或缺少必要字段使用jq校验 JSON 格式性403DSN 或 Project Key 不对检查 DSN 的 public key 是否属于该项目413请求体超过服务端限制压缩体积、裁剪日志长度、检查 nginx/Relay 配置429配额或频率限制做指数退避重试并降低采样率我在中转服务里还加了一个细节如果返回非 2xx就把原始 Envelope 的前几百字节存下来避免排查时没有现场数据。这个小习惯帮我在很多次对接问题里快速定位到是格式问题还是网络问题。5. 从SDK到Relay的链路细节体积、配额和存储策略写了一段时间附件上传以后你会发现真正的问题往往不在 SDK 层而在中间链路层。从浏览器端发起请求到数据最终出现在 Sentry Issue 里中间会经过 SDK、传输层、Relay、Kafka、对象存储等多个环节每一个环节都有各自的限制。5.1 Relay 与 Nginx 的体积上限Self-hosted Sentry 的默认部署方式里前端 Nginx 接收 POST 请求后转发给 Relay 或 Django。Nginx 默认的client_max_body_size可能是 1m这个值在普通事件上报时没问题因为事件 JSON 通常只有几 KB但一旦挂上附件请求体很容易超过 1MB就会返回 413。我在 Docker 部署环境里遇到过一次排查了很久才发现不是代码问题而是 Nginx 配置。解决办法是在 nginx 配置里调大client_max_body_size 25m;同时 Relay 配置文件config.yml里也可能有体积相关的限制自托管版本通常可以在 Relay 的 global config 中调整。如果部署在 SaaS 上这个限制是后台的超出限制就会被拒。5.2 附件与事件量的配额关系另一个容易被忽略的是配额。Sentry 的计费体系里事件量和附件量有时是独立计费的。SaaS 版本如果附件消费超出了计划额度会出现整体上报被限流的情况连事件都会被一起丢掉而不仅是附件被丢。这一点非常坑因为一旦超限你可能在后台看不到任何新 Issue很容易误判成 SDK 坏了。自托管版本虽然没有配额限制但有存储和性能压力。附件上传过多时Kafka 队列会积压Redis 缓存占用会升高ClickHouse 的写入延迟也可能被拖慢。所以附件数量要控制不能什么错误都挂同样的文件。5.3 动态降级策略基于上面的考虑我后来在团队里推行了一套“按错误级别动态降级”的附件策略错误级别是否挂附件挂哪些内容理由fatal是完整日志 网络请求 截图必须保留完整现场error是最近 50 行日志 请求摘要保留核心上下文warning否不挂附件低频噪音不值得占存储info否不挂附件正常事件实现方式很简单就是在上报入口处读一下错误级别再决定要不要往 scope 上挂附加内容。这样能保证 fatal/error 级别的排查有足够信息又不会让整个系统的存储成本失控。5.4 浏览器端的内存开销还有一个很少有人提到的点前端 SDK 在发送附件时会把附件数据序列化进请求体。如果你准备的是一个特别大的文件浏览器可能先把整个内容读入内存再把内容编码成 multipart 或 envelope 的二进制流这个过程会造成明显的内存峰值。我实际测过一个 50MB 的日志文件在普通配置的 Windows Chrome 上直接导致页面卡死。后来规范里硬性规定前端单附件不超过 2MB如果日志量太大只取最近 N 行或最后 N KB。如果确实需要传较大的文件建议先尝试压缩成 gzip 格式再上传并在 Sentry 后台下载后做解压查看。这样网络传输压力和浏览器内存压力都会小很多。6. 排查“附件没出现”的完整链路与实用排查表附件写完了也上传了但 Sentry 后台 Issue 里就是看不到附件。这种情况我们团队遇到不止一次。如果你也遇到了建议按下面的顺序排查。6.1 先确认原始请求是否真的带了附件这一步是最基础的但也最容易忽略。打开浏览器 DevTools 的 Network 面板找到上报到/envelope/的那条请求查看 Request Body 里有没有 attachment 相关内容。如果你是手动构造的 Envelope这一步更适合直接用临时打印日志来验证。实际操作中最有效的做法是单独写一个纯前端测试页面用 DSN 和最简单的scope.addAttachment发送一条测试事件。这个页面的干净程度能帮你排除很多业务代码干扰。如果测试页面能挂上附件说明 SDK 本身没问题问题大概率出在业务代码的逻辑分支上。6.2 检查 SDK 版本和 API 差异Sentry 前端 SDK 的附件 API 在版本之间有一些调整。比如某些 6.x 版本根本不支持附件7.x 以后才稳定支持React Native 平台的附件支持又跟 Web 平台不完全一致。如果你在低版本上使用scope.addAttachment可能发现方法根本不存在或者存在但被静默忽略了。排查方式很简单打开 SDK 的 debug 模式在初始化时加debug: trueSDK 会在控制台打印它准备发送的 envelope 内容摘要。我记得有几次看了 SDK 的 debug 输出一眼就发现 event payload 里压根没有 attachment 字段问题迎刃而解。6.3 确认是否被限制或延迟附件已经成功传到服务端了但后台还看不到有可能是索引和对象存储之间的同步延迟。SaaS 版本有时候附件会出现几分钟的延时Self-hosted 版本如果对象存储配置有问题也可能只保存了附件但没建索引。此外确认一下项目的“附件下载权限”设置。Sentry 的 Issue Attachment 区域默认是给项目成员看的但如果 API Key 权限不足可能也看不到附件内容。这个权限跟组织设置相关不在代码层。6.4 排查表总结现象最可能原因优先排查方式测试页面能传业务页面不能传业务代码里 scope 上下文变化 / 条件分支导致没有执行挂载代码在挂载代码前后加日志请求 body 里没有附件scope.addAttachment未在captureException前同步调用调整挂载时机请求 body 有附件但 Issue 无附件SDK 版本过低 / beforeSend 修改了 event升级 SDK / 移除 beforeSend 对 event 的修改返回 413体积超过 Nginx 或 Relay 限制调大client_max_body_size或压缩附件返回 429配额超限查看 Sentry 项目配额设置附件显示但无法预览文件内容格式不是文本/图片修改content_type附件延迟较大对象存储索引不同步等待 2~5 分钟后再刷新我个人遇到最多的是第一种业务代码路径复杂有些分支走了captureMessage有些分支走了captureException但只有其中一个分支挂载了附件。看起来是“附件没上传”实际上是业务逻辑没覆盖全。建议团队里统一封装一个工具函数集中处理“采集事件 挂附件”的逻辑避免散落在各处导致遗漏。排查附件问题最忌从 UI 下手因为后台的显示是最终结果不是过程。从浏览器请求、SDK 日志、服务端日志这三个环节逐层看一般很快就能定位到问题在哪一层。7. 自托管场景下的附件存储与扩容建议如果你和我一样维护的是 Self-hosted Sentry还要考虑附件存储的长期规划。Sentry 的附件在磁盘上占用不小尤其是录屏和截图类型。默认情况下 Self-hosted 会使用 Docker volume 存储这些文件时间久了磁盘占用会迅速增长。我见过一个早期部署跑了半年没清理最终持久化目录占了 200 多GB。建议给附件目录单独做监控设置磁盘使用率告警同时开启对象的自动清理策略。Sentry 后台有 Retention 设置可以设定事件和附件的保留天数。如果业务上不需要长期保存附件可以把附件保留期调得跟事件保留期一致甚至更短。另外如果附件主要用于短期排障可以定期用脚本导出感兴趣的附件到内部归档系统再清掉 Sentry 里的原始文件。这样既不影响近期的排查也不会让 Sentry 存储无限膨胀。还有一些团队会把附件直接关掉只保留事件本身用日志系统作为补充排查工具。这当然是一种取舍但如果要解决“只有一堆栈没有上下文”的困境附件几乎是性价比最高的方案。我的观点是不要因为存储成本就完全拒绝附件而是通过体积预算、级别降级、定期清理来把成本控制在可接受范围内。附件机制这个功能越用到后面越觉得它是 Sentry 从“错误收集工具”走向“现场还原工具”的关键一步。只要把数据边界、体积预算、链路限制和排查方法都摸清了再用起来就会顺手得多。