mgurevin/recorder

GitHub: mgurevin/recorder

一个生产安全的 Go HTTP 客户端交互记录器,将完整的请求生命周期捕获为可移植的 HAR 1.2 文档并支持流式脱敏与敏感值保护。

Stars: 0 | Forks: 0

# recorder [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/ad/ad5834178f7599af9fdda11629d49cae07f2997beec49821b2920eff5bfd50e7.svg)](https://github.com/mgurevin/recorder/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/mgurevin/recorder.svg)](https://pkg.go.dev/github.com/mgurevin/recorder) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) `recorder` 是一个 `http.RoundTripper`,它将 `net/http` 客户端交互的完整生命周期记录为 **HAR 1.2** 文档。它不仅记录成功的响应,也同样详尽地记录在响应之前或期间失败的调用:DNS 解析、TCP 连接、TLS 握手、代理拨号、context 取消以及请求/响应 body 流错误——每一项都被分类归入结构化的 `_error` 扩展中。 - **核心包仅依赖标准库。** 可选的集成(OpenTelemetry 适配器、HAR 检查器 UI)位于独立的模块/目录中,绝不会将依赖项引入核心包。 - **调用者可见的 HTTP 行为永远不会改变。** 原始请求不会被修改,响应字节/EOF/错误原封不动地透传,并且 recorder 的失败绝不会中断 HTTP 调用。 - **脱敏仅应用于记录副本。** 实时的请求和响应永远不会被修改;密钥仅在记录的副本中被替换。 - **绝不凭空捏造。** 无法观测到的值(网络报头大小、透明 gzip 后的压缩大小、未测量的时序阶段、代理背后的原始 IP)根据 HAR 1.2 标准被记录为 `-1` 或被省略。 ## 动机 在可能需要后续对请求进行调查、对账或审计的系统中,可靠的外部 HTTP 交互记录至关重要。这在金融工作流中尤为重要,因为仅靠成功的响应可能无法提供足够的上下文来解释操作故障或有争议的交易。 大多数 HTTP 记录工具侧重于已完成的请求/响应对。然而,在生产环境中,故障可能发生在交互的任何阶段:DNS 解析、TCP 连接、代理协商、TLS 握手、请求传输、响应流式传输或 context 取消。诊断这些故障需要传输层面的时序和错误信息以及 HTTP 数据。 `recorder` 将这种完整的客户端交互生命周期捕获为可移植的 HAR 1.2 文档,同时保持 Go 的 `net/http` 栈的行为。它仅记录可观测到的数据,保持 body 捕获为可选开启,并对记录的副本应用可配置的脱敏和敏感值保护。 生成的产物适用于调试、故障分析、对账和受控的审计工作流,而不会使 recorder 本身成为应用程序故障的新来源。 ## 环境要求 - 核心模块:Go 1.24 或更高版本。 - `otelrecorder` 模块:Go 1.25 或更高版本,与其 OpenTelemetry 依赖项相匹配。 CI 会在每个模块最低支持的 Go 版本和当前稳定的 Go 发行版上进行测试。 ## 快速开始 ``` package main import ( "io" "net/http" "os" "github.com/mgurevin/recorder" ) func main() { rec := recorder.NewMemoryRecorder() client := &http.Client{ Transport: recorder.NewTransport(http.DefaultTransport, rec), } resp, err := client.Get("https://example.com/") if err == nil { io.Copy(io.Discard, resp.Body) // the entry finalizes on body EOF/Close resp.Body.Close() } rec.WriteHAR(os.Stdout) // or: har := rec.HAR() } ``` 当 `RoundTrip` 返回时,条目**并没**完成——此时响应 body 尚未被读取。只有当 body 到达 EOF、被提前关闭或读取失败时,它才会到达 recorder;传输错误会立即完成记录。一个从未被读取*且*从未被关闭的 body 不会生成任何条目(这是一个调用者的 bug,在普通的 `net/http` 中也会导致连接泄漏)。 ## 记录内容 | 领域 | 记录内容 | | --- | --- | | HAR 请求/响应 | method, URL, 状态, headers(可观测时保持网络传输顺序), cookies, body 元数据 | | timings | blocked, dns, connect, ssl, send, wait, receive | | `_error` | 传输/body 失败:阶段、类型、消息、timeout/context 标志、unwrap 链 | | `_network` | DNS 结果及合并、本地/远程地址、IP 版本、复用/空闲、proxy、HTTP/2、空闲池返回 | | `_tls` | TLS 版本、密码套件、ALPN、SNI、恢复、OCSP/SCT、证书链 | | `_requestBody` / `_responseBody` | 完成情况、提前关闭、截断、总字节数/捕获字节数、哈希、存储引用 | | trailers / transfer encoding | `_requestTrailers`, `_responseTrailers`, `_*TransferEncoding` | | `_trace` | 原始 httptrace 事件时间线(启用时);详细信息会通过 `RedactErrorMessage` 透传 | | `_expect100` | `Expect: 100-continue` 握手(等待、接收、等待时间) | | `_informational` | 1xx 临时响应(100, 103 Early Hints),包含已脱敏的 headers | | 关联性 | `_traceId`, `_exchangeId`, `_redirectIndex` | 去除所有以 `_` 为前缀的字段后,将留下一个有效的纯 HAR 1.2 文档(已经过测试验证);这些文件可以在标准的 HAR 查看器中打开。 安全问题应按照 [SECURITY.md](SECURITY.md) 中的说明私下报告。发布历史记录在 [CHANGELOG.md](CHANGELOG.md) 中维护,可复现的性能测量和配置指南记录在 [BENCHMARK.md](BENCHMARK.md) 中。 ## 默认配置 `NewTransport` 从 `DefaultOptions()` 开始,并在此基础上应用您的 `Option` 值: | Option 字段 | 默认值 | 说明 | | --- | --- | --- | | `CaptureRequestBody` | `false` | 跟踪生命周期和字节计数;内容捕获为可选开启 | | `CaptureResponseBody` | `false` | 跟踪生命周期和字节计数;内容捕获为可选开启 | | `EmbedBodies` | `false` | 默认不嵌入 body 文本 | | `MaxRequestBodyBytes` | `1 MiB` | 内容捕获限制;`<= 0` 表示无限制 | | `MaxResponseBodyBytes` | `1 MiB` | 内容捕获限制;`<= 0` 表示无限制 | | `CaptureTLS` | `true` | 启用 `_tls` 扩展 | | `CaptureCertificates` | `true` | 启用对端证书元数据 | | `CaptureRawCertificates` | `false` | 默认不嵌入原始 DER | | `CaptureHeaders` | `true` | 记录 headers 和 trailers | | `CaptureCookies` | `true` | 记录解析后的 cookies | | `RedactHeaders` | `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key` | 不区分大小写 | | `RedactQueryParameters` | 空 | 可选开启 | | `RedactCookies` | 空 | 可选开启;当其载体 header 被脱敏时,cookie 也会被脱敏 | | `RedactJSONFields` | 空 | 可选开启 | | `RedactXMLElements` | 空 | 可选开启 (SOAP bodies) | | `HashBodies` | `false` | 全流哈希为可选开启 | | `BodyHashAlgorithm` | `sha256` | 支持 `sha1`/`md5`;未知值回退到 sha256 | | `CaptureRawTrace` | `false` | 默认禁用原始 httptrace 事件列表 | | `ContentDecoders` | `gzip`, `x-gzip`, `deflate` | 记录时解码的标准库解码器 | | `BodyRedactors` | 空 | 精确的基础 MIME 注册;自定义脱敏器覆盖内置脱敏器 | | `BodyCapturePolicy` | `nil` | 可选的按请求/按响应决策;失败时仅记录元数据 | | `BodyStore` | `MemoryBodyStore` | 为 nil 时使用 | | `InternalErrorMode` | `InternalErrorIgnore` | 如果设置,则通过 `OnInternalError` 报告 | | `OnInternalError` | `nil` | 用于 recorder 内部错误的可选回调 | | `Logf` | `nil` | 供 `InternalErrorLog` 使用;nil 时回退到标准 log 包 | | `OnEntryCompleted` | `nil` | 可选的按条目回调(context + 条目) | | `RedactErrorMessage` | `nil` | 可选的错误消息脱敏器 | 另请注意: - `WithOptions(Options{})` 会替换整个结构体:零值选项会禁用可选的 headers、cookies、TLS、body 内容、嵌入、哈希和原始 trace 捕获。核心交互字段加上 body 生命周期、字节计数和完成状态仍会被记录。 - `Options` 不得在 transport 处理其第一个请求后被修改。 ## 选项参考 | 选项 | 效果 | | --- | --- | | `WithOptions(o)` | 替换整个 `Options` 值;组合使用时首先应用 | | `WithCaptureRequestBody(v)` | 切换请求 body 内容捕获(始终跟踪大小/状态) | | `WithCaptureResponseBody(v)` | 切换响应 body 内容捕获 | | `WithEmbedBodies(v)` | 关闭:保留大小/哈希/存储引用但不嵌入 body 文本——这是使用 `FileBodyStore` 时的生产环境设置 | | `WithMaxRequestBodyBytes(n)` | 请求捕获限制;计数会继续超过此限制 | | `WithMaxResponseBodyBytes(n)` | 响应捕获限制;同时限制记录时的解码 | | `WithCaptureTLS(v)` | 切换 `_tls` 扩展 | | `WithCaptureCertificates(v, raw)` | 切换证书详情;`raw` 嵌入 Base64 DER | | `WithCaptureHeaders(v)` | 切换 header/trailer 记录 | | `WithCaptureCookies(v)` | 切换解析后的 cookie 记录 | | `WithRedactHeaders(names...)` | 追加不区分大小写的 header 名称以进行脱敏 | | `WithRedactQueryParameters(names...)` | 在 `url` 和 `queryString`(以及表单字段)中脱敏查询参数 | | `WithRedactCookies(names...)` | 按名称脱敏 cookies | | `WithRedactJSONFields(names...)` | 递归脱敏捕获的 bodies 中的 JSON 对象字段 | | `WithRedactXMLElements(names...)` | 按本地名称脱敏 XML 元素子树(忽略命名空间前缀) | | `WithHashBodies(enabled, alg)` | 切换 body 哈希 / 选择算法 | | `WithBodyStore(s)` | 用于捕获字节的存储后端(`MemoryBodyStore`, `FileBodyStore`,自定义) | | `WithCaptureRawTrace(v)` | 将每个原始 httptrace 事件记录在 `_trace` 下 | | `WithContentDecoder(enc, dec)` | 为 `Content-Encoding` 注册记录时解码器(例如 brotli, zstd) | | `WithBodyRedactor(mediaType, redactor)` | 为精确的基础 MIME 类型注册流式脱敏器;最后一次注册生效 | | `WithBodyCapturePolicy(policy)` | 为每个 body 覆盖捕获、嵌入、哈希、限制或 body 脱敏器 | | `WithInternalErrorMode(m)` | `Ignore`(默认)或 `Log`;recorder 失败永远不会改变 HTTP 结果 | | `WithOnInternalError(fn)` | 用于 recorder 内部错误的回调 | | `WithLogf(fn)` | `InternalErrorLog` 使用的日志记录器 | | `WithOnEntryCompleted(fn)` | 按条目完成的回调——集成钩子(OTel 适配器使用它) | | `WithErrorRedactor(fn)` | 应用于每个记录的错误消息的过滤器 | ## 生产环境配置方案 ### 完整取证捕获 显式启用 body 捕获并添加您的 payload 所需的脱敏规则: ``` tr := recorder.NewTransport(base, rec, recorder.WithCaptureRequestBody(true), recorder.WithCaptureResponseBody(true), recorder.WithEmbedBodies(true), recorder.WithHashBodies(true, "sha256"), recorder.WithRedactQueryParameters("token", "api_key"), recorder.WithRedactJSONFields("password", "secret"), recorder.WithMaxResponseBodyBytes(4<<20), ) ``` ### 海量遥测 ``` tr := recorder.NewTransport(base, rec, recorder.WithCaptureRequestBody(true), recorder.WithCaptureResponseBody(true), recorder.WithEmbedBodies(false), // no body text in the HAR recorder.WithBodyStore(recorder.FileBodyStore{Dir: "/var/spool/recorder"}), recorder.WithMaxResponseBodyBytes(64<<10), // small capture budget recorder.WithHashBodies(false, ""), // hashing caps large streams at ~SHA-256 speed ) ``` 哈希涵盖每一个流式传输的字节,对于大型 body,它可能成为吞吐量的瓶颈。在禁用完整性元数据之前,请查阅 [BENCHMARK.md](BENCHMARK.md),并在部署的硬件上比较 `Benchmark100MBStreamingBody` 和 `Benchmark100MBStreamingBodyNoHash` 的表现。 ### 默认仅捕获 Header ``` tr := recorder.NewTransport(base, rec) ``` ### SOAP/XML 脱敏 ``` tr := recorder.NewTransport(base, rec, recorder.WithRedactXMLElements("Username", "Password"), // covers etc. ) ``` ### 带有 Trace ID 的按请求导出 ``` ctx, traceID := recorder.TraceContext(context.Background()) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := client.Do(req) // redirect hops share the trace ID if err == nil { io.Copy(io.Discard, resp.Body) resp.Body.Close() } entries := rec.TakeTrace(traceID) // atomically remove + return this call's entries recorder.NewHAR(entries).Write(f) // standalone HAR for just this call ``` ### 流式 NDJSON 导出 `JSONStreamRecorder` 会在每个条目完成的那一刻将其作为一行 JSON 写入——没有缓冲,并且特意不输出顶层的 HAR 包装器,从而确保 JSON 文档永远不会只写了一半: ``` stream := recorder.NewJSONStreamRecorder(w) tr := recorder.NewTransport(base, stream) ``` ### OpenTelemetry 适配器 请参阅下方的 [OpenTelemetry 导出](#opentelemetry-export-otelrecorder)——该适配器位于独立的 `otelrecorder` 子模块中,并接入 `WithOnEntryCompleted`。 ## Body 捕获、存储和哈希 三个独立的关注点: - **计数** 始终运行,即使关闭了捕获:`totalBytes` 和 HAR 大小字段也会反映超出任何限制的真实流。 - **捕获** 将内容写入 `BodyStore`,直到达到 `Max*BodyBytes`;超过限制后仅继续计数(和哈),并将记录标记为 `truncated`。`MemoryBodyStore`(默认)在内存中缓冲;`FileBodyStore` 将数据缓存到临时文件中,因此大型 body 永远不会驻留在内存中——**清理其文件是调用者的责任**(路径通过 `_requestBody`/`_responseBody.store` 公开)。 - 选定的内置或自定义 body 脱敏器作为流式转换在字节到达 BodyStore 之前运行。它不会先写入原始 body 然后再覆盖它。每个 body 选择一个脱敏器,调用一次 `Redact`,将每个输入字节通过其返回的 writer 传递一次,然后关闭该 writer。嵌入操作会重用已经脱敏的存储表示。 - **嵌入** (`EmbedBodies`) 决定捕获的内容是否成为 HAR 中的 `postData.text` / `content.text`。使用 `WithEmbedBodies(false)` 时,文档保持较小,同时保留大小、哈希、截断状态和存储引用。 哈希涵盖**整个**流(截断不会影响它们),并且仅针对完整的流输出——部分流的哈希会产生误导。 ### 单次交互捕获策略 `WithBodyCapturePolicy` 可以独立地为每个请求和响应覆盖全局 body 选项。回调接收不可变的元数据以及从 transport 当前的 `Options` 派生的决策: ``` policy := recorder.BodyCapturePolicyFunc(func( ctx context.Context, meta recorder.BodyCaptureMeta, decision recorder.BodyCaptureDecision, ) (recorder.BodyCaptureDecision, error) { if meta.Direction == recorder.ResponseBody && meta.StatusCode >= 500 { decision.Capture = true decision.Embed = false decision.Hash = true decision.MaxBodyBytes = 256 << 10 } return decision, nil }) transport := recorder.NewTransport(base, rec, recorder.WithBodyCapturePolicy(policy), ) ``` 请求决策在 HTTP 调用之前运行,因此状态码为零。响应决策在响应 headers 到达后运行,可以检查状态和内容元数据。决策按物理交互冻结,包括重定向跳转。返回 `Capture=false` 也会禁用嵌入和按 body 的脱敏器。策略错误和 panic 通过 `OnInternalError` 报告,并失败关闭为仅记录元数据,而不会更改实时的 HTTP 结果。策略可能会被并发调用,并且必须快速返回。 ## 脱敏 - **Headers、查询参数、cookies** 按不区分大小写的名称脱敏;当 cookie 的载体 header(`Cookie` / `Set-Cookie`)在 header 列表中时,该 cookie 也会被脱敏。 - **URL 编码的表单脱敏** 在 `application/x-www-form-urlencoded` bodies 流入 BodyStore 时应用查询参数规则。匹配的值变为 `%5BREDACTED%5D`;重复字段、排序、键拼写、分隔符和每个未匹配的字节保持不变。相同的脱敏表示支持 `postData.text` 和 `postData.params`。 - **Multipart 表单脱敏** 将这些规则应用于显式 `multipart/form-data` bodies 中的部件名称。匹配的字段/文件 payload 变为 `[REDACTED]`;匹配的文件还会对其 `filename` 元数据进行脱敏。未匹配的部件和 multipart 框架保持逐字节不变。有歧义的 headers、无效的边界和未匹配的嵌套 multiparts 会停止存储捕获,而不是回退到原始字节。 - **JSON 字段脱敏** 递归替换匹配的对象字段值,同时保留每个未脱敏的字节(包括空格、键顺序、重复键、数字拼写和转义)。支持处理 UTF-8 BOM 输入以及 `application/x-ndjson` 中的每个文档。 - **XML 元素脱敏** 替换匹配元素内的完整子树(按本地名称,忽略命名空间前缀——`"Password"` 涵盖 ``),逐字节保留文档的其余部分。XML **属性值不会被脱敏**。 - 在匹配的 XML 子树内,不匹配或过早关闭的标签会保持抑制状态;格式错误的标记不会提前结束脱敏。 - 一个有界的前缀嗅探器可以识别在通用或不正确的内容类型(例如 `text/plain`)下发送的 JSON/XML。 ### 敏感值保护模式 内置规则默认使用 `[REDACTED]`。它们也可以为授权恢复加密值,或者为关联创建确定性的、不可逆的 token: ``` keys := recorder.ProtectionKeyProviderFunc(func(mode recorder.ProtectionMode) (recorder.ProtectionKey, error) { switch mode { case recorder.ProtectionEncrypt: return recorder.ProtectionKey{ID: "enc-2026-07", Key: encryptionKeyFromKMS}, nil // exactly 32 bytes case recorder.ProtectionTokenize: return recorder.ProtectionKey{ID: "tok-2026-07", Key: tokenKeyFromKMS}, nil // at least 32 bytes default: return recorder.ProtectionKey{}, errors.New("unsupported protection mode") } }) transport := recorder.NewTransport(base, rec, recorder.WithRedactJSONFields("password", "accountNumber"), recorder.WithSensitiveValueProtection(recorder.SensitiveValueProtection{ Mode: recorder.ProtectionEncrypt, KeyProvider: keys, MaxValueBytes: 64 << 10, }), ) ``` 这些模式对于一个 transport 是互斥的: - `ProtectionRedact` 写入 `[REDACTED]` 并且不需要密钥。 - `ProtectionEncrypt` 使用 AES-256-GCM 写入 `REC-ENC-v1..`,对每个值使用全新的 96 位 `crypto/rand` nonce。密钥 ID 作为附加数据进行验证。匹配的值仅缓冲至 `MaxValueBytes`(默认为 64 KiB,硬限制为 16 MiB)。 - `ProtectionTokenize` 使用 HMAC-SHA-256 写入 `REC-TOK-v1..`。它通过 HMAC 流式传输值而无需缓冲,并为受同一密钥保护的值启用等价关联。它不是加密,无法恢复原始值。 如果密钥提供程序失败、密钥长度无效、随机 nonce 生成失败或加密值超出其限制,该值将变为 `[REDACTED]`。原始明文永远不会用作后备。非正数限制会选择安全的默认值;它永远不意味着无限制。 密钥提供程序、密钥验证、随机性和加密失败也会通过 `OnInternalError` 和 `InternalErrorLog` 报告。为了防止损坏的密钥服务为每个值生成一条日志,失败会被聚合为每个请求/响应方向和交互报告一次;该报告包含受影响的值计数并包装了第一个原因。预期的 `value_too_large` 策略回退仍然在 `_redaction` 中可见,但不是内部错误。 保护涵盖了由现有 header、查询、cookie、JSON、XML、URL 编码表单和 multipart 规则选择的值。JSON 加密确切的原始 JSON 值(包括其引号或容器语法);XML 加密匹配的外部元素内的字节;表单加密原始编码值;multipart 加密部件 payload 并单独保护匹配的文件名。所有未匹配的字节保持相同的逐字节保证。 使用独立的加密和标记化密钥,从 KMS 或密钥管理器获取它们,并通过更改非机密密钥 ID 和活动密钥材料来轮换它们。只要记录的数据必须可恢复,就应保留旧的解密密钥。不要将保护密钥重用于不相关的协议。因为标记化是确定性的,所以它会泄露相等性,并且在输入域很小时容易受到猜测攻击;对于低熵的密钥,请使用加密或完全脱敏。 `DecryptProtectedValue` 和 `VerifyProtectedToken` 专为受信任的服务器端工具提供。切勿将明文或密钥放在 HAR 元数据、日志、URL、命令历史记录或持久的浏览器存储中。 ### 自定义 body 脱敏器 实现 `BodyRedactor` 以添加针对另一种媒体类型的流式转换: ``` type BodyRedactor interface { Redact(dst io.Writer, contentType string) (io.WriteCloser, error) } transport := recorder.NewTransport(base, rec, recorder.WithBodyRedactor("text/csv", csvRedactor), ) ``` 注册时精确匹配规范化的基础 MIME 类型,忽略大小写和参数。自定义注册会覆盖同一类型的内置处理程序,并且最后一次注册生效。`Redact` 可能针对不同的 bodies 并发运行;每个返回的 writer 属于一个 body,必须 flush 但不能关闭 `dst`。构造函数、写入、关闭、panic 和短写入失败会停止捕获,通过 `OnInternalError` 报告,并且永远不会改变实时的 HTTP 交互。自定义脱敏器是受信任的流式组件:保持它们自身的缓冲区在有界范围内,并在无法安全解析输入时失败关闭。 返回的 writer 可以选择实现 `BodyRedactionReporter`。其替换计数会导出到 `_redaction` 中;没有 reporter 的 writer 仅记录为 `processed`。报告仅包含计数,绝不包含规则名称或原始值。 ### 脱敏审计元数据 当记录的值被更改或 body 脱敏器运行时,条目会包含 `_redaction`。它总结了请求/响应 URL、header、查询、cookie 和 body 的工作,以及更改的错误和原始 trace 消息。内置的 body 脱敏器报告 `redacted`、`unchanged` 或 `failed` 以及替换计数;没有可选 reporter 的自定义脱敏器使用 `processed`。 保护摘要还会统计 `redacted`、`encrypted` 和 `tokenized` 的结果,以及诸如 `value_too_large` 之类的固定失败关闭原因代码。它们从不包含密钥 ID、token、规则名称、明文或底层错误消息。 该扩展特意排除了配置的字段/header/cookie 名称、原始值、具体的 Go 类型名称和错误文本。缺少 `_redaction` 意味着没有观察到审计事件;这并不能证明较早的 HAR 是在没有脱敏的情况下生成的,因为早期版本并未输出此扩展。 普遍适用的规则: - 脱敏**仅应用于记录的副本**——实时的 HTTP 请求和响应永远不会被修改。 - 流式解析器将 JSON/表单键、XML 标签和 multipart 部件 headers 的上限设为 64 KiB;JSON/XML 嵌套上限设为 1024,MIME 嗅探上限设为 4 KiB。超过限制会停止存储捕获,而不是回退到未脱敏的字节。 - 由于流式 sink 无法回滚已提交的输出,因此即使后续输入被证明格式错误或不完整,匹配的字段也会保持脱敏状态。 - 出现在字段/元素之前的格式错误语法可能会阻止解析器识别后面的匹配项;不要依赖 body 脱敏作为输入验证机制。 - Body 哈希是基于真实的网络/调用者字节计算的,绝不是基于脱敏后的字节。 - 错误消息也可能带有密钥:`WithErrorRedactor` 会过滤掉所有记录的错误字符串。 ## 故障与错误分类 HTTP 4xx/5xx **不是**传输错误——它们作为正常响应被记录。传输和 body 流失败会产生 `_error` 扩展: ``` { "_error": { "phase": "dns", "type": "*net.DNSError", "message": "lookup api.example.com: no such host", "timeout": false, "temporary": false, "contextCanceled": false, "contextDeadlineExceeded": false, "unwrapChain": ["*url.Error", "*net.OpError", "*net.DNSError"] } } ``` 阶段:`request_setup`, `dns`, `connect`, `proxy`, `tls`, `write_request`, `write_request_body`, `wait_response`, `read_response_headers`, `read_response_body`, `redirect`, `context`, `unknown`。 分类以类型优先(在 unwrap 链上进行 `errors.Is`/`errors.As`),并以 httptrace 进度作为后备;字符串匹配仅作为无类型错误的最后手段。有两个区别值得注意: - `context` 阶段和 `contextCanceled`/`contextDeadlineExceeded` 标志要求请求**自身的 context** 已被触发。Transport 内部的超时(例如 `ResponseHeaderTimeout`)仅通过 `errors.Is` 匹配 context 错误,并归因于交互所处的阶段(`wait_response`),且带有 `timeout: true`。 - 响应 body **读取错误**记录 `_error` 阶段为 `read_response_body`;调用者**提前关闭**的 body 不是错误——它记录状态为 `closed_early` 和 `_responseBody.closedEarly`。 ## 时序与可观测性限制 HAR 的 `timings` 单位是毫秒;`-1` 表示“未观测到/不适用”,绝不代表零: - 复用的连接(包括 HTTP/2 多路复用)对于 dns/connect/ssl 报告为 `-1`——这些阶段没有在该交互中发生;`blocked` 涵盖了等待连接池连接的时间。 - `headersSize` 始终为 `-1`:写入网络的确切字节数(包括 transport 添加的 headers、HTTP/2 上的 HPACK)在 RoundTripper 层是无法观测的。 - 使用透明 gzip 时,`bodySize` 为 `-1`,`content.size` 是解码后的大小(`content._decoded: true`)。 - 在代理之后,TCP 对端就是代理:`serverIPAddress` 被省略,`_network` 的地址描述的是代理连接。对于标准的 `*http.Transport`,在经过密码和配置的查询参数脱敏后,`_network.proxy` 包含选定的代理 URL。自定义 RoundTripper 可能仅将拨号的代理地址作为 `host:port` 后备公开。 - 既未被读取也未被关闭的响应 body 永远不会完成——不会生成条目(设计上没有终结器)。 - `_network.putIdle` 是尽力而为的:连接返回空闲池的过程可能与条目的最终化发生竞争,因此缺失意味着“未观测到”。 ## 压缩与内容解码 1. **透明 gzip** ——当您不设置 `Accept-Encoding` 时,`http.Transport` 会自行协商并解压 gzip。记录存储解码后的字节(`_decoded: true`),`bodySize` 为 `-1`。 2. **记录时解码** ——当请求或响应具有显式的 `Content-Encoding` 并且注册了解码器时,body 脱敏会将解码后的形式流式传输到 BodyStore 中。请求和响应的哈希/计数器仍然描述所流动的编码字节;响应的 `bodySize` 保持网络视图。 `gzip`、`x-gzip` 和 `deflate`(zlib 包装或原始形式,像浏览器一样进行嗅探)默认使用标准库提供支持。Brotli/zstd 特意不打包在内;使用事实上的标准纯 Go 实现来配置它们只需要几行代码(零传递依赖,无需 cgo): ``` recorder.WithContentDecoder("br", func(r io.Reader) (io.ReadCloser, error) { return io.NopCloser(brotli.NewReader(r)), nil // github.com/andybalholm/brotli }) recorder.WithContentDecoder("zstd", func(r io.Reader) (io.ReadCloser, error) { zr, err := zstd.NewReader(r) // github.com/klauspost/compress/zstd if err != nil { return nil, err } return zr.IOReadCloser(), nil }) ``` 安全保障:超过已解析的方向性 body 限制(`MaxRequestBodyBytes` 或 `MaxResponseBodyBytes`,除非策略覆盖它)的解码输出将被拒绝,因此压缩炸弹无法耗尽捕获配额。当 body 脱敏处于活动状态,未知/多步骤编码和解码器失败会停止存储捕获,而不是回退到原始字节。失败会通过 `OnInternalError` 报告,并且永远不会影响实时的请求或调用者接收到的字节。 ## Recorder 实现 | Recorder | 存储条目? | TraceStore? | 最适用于 | | --- | --- | --- | --- | | `MemoryRecorder` | 是 | 是 | 测试、短期捕获、`HAR()`/`WriteHAR` | | `HARFileRecorder` | 是,直到 flush | 是 | 在 `Flush`/`Close` 时原子写入完整的 HAR 文件 | | `JSONStreamRecorder` | 否 | 否 | 流式 NDJSON,每个条目一行 | | `RecorderFunc` | 由调用者定义 | 否 | 自定义回调 | 所有内置的 recorder 都可以安全地用于并发;条目是不可变的快照。只有最终化的条目才会到达 recorder——进行中的交互不会出现在任何导出中。 ## Trace 关联 `http.Client` 在 transport 之上遵循重定向,因此每一跳都是一个单独的条目。通过 context 关联它们: - `recorder.WithTraceID(ctx, id)` ——安装您的关联 ID;每一跳都将其记录为 `_traceId`,并带有递增的 `_redirectIndex`。 - `recorder.TraceContext(ctx)` ——同上,使用生成的随机 ID。 `MemoryRecorder` 和 `HARFileRecorder` 实现了可选的 `TraceStore` 功能(通过对 `Recorder` 进行类型断言发现): - `EntriesByTrace(id)` ——返回该 trace 的条目,并将它们保存在存储中 - `RemoveTrace(id)` ——删除该 trace 的条目 - `TakeTrace(id)` ——**原子地**移除并返回它们;并发最终化的条目永远不会落入查询和删除之间 `MemoryRecorder` 还额外提供了 `HARForTrace(id)` 作为便捷助手,用于为一个 trace 构建 HAR 文档;它不是 `TraceStore` 接口的一部分。对于其他 recorder,请将 `TakeTrace`/`EntriesByTrace` 与 `recorder.NewHAR(entries)` 结合使用。 ## HAR 检查器 该仓库在 [`inspector/`](inspector/) 下提供了一个本地的 React + TypeScript 查看器,用于查看该库生成的 HAR 文件,包括每一个 `_` 扩展字段: ![HAR Inspector screenshot](https://static.pigsec.cn/wp-content/uploads/repos/cas/ba/ba7773ca91ce6d5d4f5e425d20d25739b811618148e284c3a27f4fdd2ffe5201.png) [打开实时 Inspector](https://mgurevin.github.io/recorder/?sample) ——从磁盘选择的 HAR 文件会在您的浏览器本地解析,不会被上传。 - 带有过滤功能的条目列表(method、状态类别、状态、错误阶段、主机/路径搜索、traceId、失败/截断/提前关闭)、排序,以及按 `_traceId` 进行的 trace 链分组。 - 详情选项卡:带有时序瀑布图的概览、timings(`-1` 显示为“未观测”)、带有 JSON/XML 漂亮打印和二进制指示器的请求/响应、`_error`、`_network`、包含证书链的 `_tls`、原始 `_trace` 时间线、保留未知扩展的原始 JSON,以及一个重构 cURL 命令的 Replay 选项卡(如果存在,包括来自脱敏后 `_network.proxy` URL 的 `--proxy`)。 - 为大型 HAR 文件提供虚拟化列表;响应式设计,向下兼容至移动端宽度。 ``` cd inspector npm install npm run dev # http://localhost:5173 npm run build npm test ``` 工作流程: ``` 1. produce a HAR with recorder (WriteHAR / HARFileRecorder) 2. open the inspector 3. drag & drop the .har file (or use the file picker / built-in sample) 4. inspect entry details tab by tab ``` **安全提示:**HAR 文件通常包含敏感数据。Inspector 完全**在您的浏览器本地解析文件**——不会向任何地方上传。请参阅 [inspector/README.md](inspector/README.md)。 ## OpenTelemetry 导出 (`otelrecorder`) [`otelrecorder`](otelrecorder/) 子模块(它本身是一个独立的 Go 模块——核心保持无依赖)通过 `OnEntryCompleted` 钩子将完成的条目导出为 OTel span 事件和指标: ``` import "github.com/mgurevin/recorder/otelrecorder" exporter, err := otelrecorder.NewExporter() // uses the global OTel providers if err != nil { // handle } tr := recorder.NewTransport(base, rec, recorder.WithOnEntryCompleted(exporter.OnEntryCompleted)) ``` 当请求 context 中存在活动的 span 时,每次交互都会成为一个 `recorder.http.exchange` span 事件;指标(持续时间/body 大小直方图、失败/提前关闭/截断计数器)始终会被记录。 **基数指导:**适配器从不导出完整的 URL、路径、查询字符串、header/cookie 值、body 内容或原始 HAR JSON。`server.address` 仅包含主机,指标标签使用状态*类别*(`2xx`、`0`),关联 ID 仅在 span 事件中存在且为可选,字符串值被限制长度。自定义属性用于用户控制的低基数维度,例如路由*模板*——绝不是原始路径或 ID。当您需要完整的 HAR 条目时,请将其写入专门的 sink(`HARFileRecorder`、`JSONStreamRecorder`);OTel 属性不是存放文档的合适位置。 ## 开发 ``` go test ./... go test -race ./... go vet ./... go test -bench . -benchmem -run '^$' # benchmark matrix; see BENCHMARK.md go test -fuzz FuzzJSONStreamRedactor # one target at a time; see *_test.go for all ``` 架构决策、不变量和已知限制记录在 [DESIGN.md](DESIGN.md) 中。基准测试方法、当前测量值以及对性能敏感的配置指南位于 [BENCHMARK.md](BENCHMARK.md) 中。
标签:API集成, EVTX分析, Go, HAR, Ruby工具, 可观测性, 数据脱敏, 日志审计, 用户代理, 网络调试, 自动化