TiyGate 请求日志:c→g→p→g→c 全链路记录规范

June 23, 2026 · View on GitHub

请求日志详情视图记录单次请求在网关内的完整四段链路。本文定义每段 记录什么、在哪里捕获、如何脱敏,确保字段齐全、客户端自定义 header 可见、且仅对敏感值脱敏。

四段链路

含义header 字段body 字段数据来源
c→g客户端 → 网关(ingress 请求)redacted_headers_jsonraw_envelope_jsonRawEnveloperequest_logs
g→p网关 → 供应商(egress 请求)egress_headers_jsonegress_bodyExchangeCapturerequest_payloads
p→g供应商 → 网关(upstream 响应)upstream_resp_headers_jsonupstream_resp_bodyExchangeCapture
g→c网关 → 客户端(client 响应)client_resp_headers_jsonclient_resp_bodyExchangeCapture

数据由两条记录拼成:c→g 来自 RawEnvelope,其余三段来自 ExchangeCapture,两者经 telemetry bus 异步落库后由 get_request_replay()request_id LEFT JOIN 拼回,前端 RequestLogs.tsx 分四个区块展示。

字段齐全约定

记录的目标是字段完整:每个实际收发的 header 都应出现,敏感值脱敏 为 [REDACTED],但 header 名与非敏感字段必须保留。

  • c→gbuild_redacted_envelope()crates/server/src/ingress/observability.rs) 遍历全部客户端请求 header(含 x-request-idx-debug-id 等自定义 header),经 Redactor 脱敏后写入 redacted_headers_json。自定义 header 明文保留,便于定位问题。

  • g→p:egress header 以 reqwest 实际构建的请求为准。在每个上游 发送点,请求 builder 经 inject_trace() 注入 traceparent.json()/.body() 写入 body 后,调用 finalize_egress()ingress/observability.rs)执行 builder.build() 得到 最终 reqwest::Request,并从 req.headers() 快照完整 header 集合, 再用 client.execute(req) 发送。这样 content-typecontent-lengthtraceparentauthorization/x-api-key(Anthropic 还有 anthropic-version)等 reqwest 在 finalize 时补齐的 header 都会被记录。

    历史问题:旧实现对手工构建的 upstream_headers 做快照,而 content-type/content-length 是 reqwest 发送时才补上的、发生在快照 之后,passthrough 路径起点更是空 HeaderMap,导致日志里只剩 authorization 与手动 push 的 traceparent。改为 build-then-capture 后字段齐全。

脱敏策略

脱敏由 crates/core/src/redaction.rsRedactor 完成(规则详见 redaction.md):

  • header 名命中精确名单(authorization/x-api-key/cookie 等)或子串 名单(token/secret/password/credential)时,值替换为 [REDACTED]header 名保留
  • JSON body 命中已知凭证键(api_key/token/client_secret 等)时递归 替换为 [REDACTED],其余字段(如 messages[].content)verbatim 保留。

g→p/p→g/g→c 三段在落库前由 OltpSink::capture_to_row()crates/store/src/log_sink/oltp.rs)统一脱敏;c→g 段在 ingress 热路径 构建 RawEnvelope 时即脱敏。捕获到的明文完整 header 只在内存中短暂存在, 落库前一定经过脱敏。

媒体剥离

  • raw_envelope_capture_media 关闭(默认)时,c→g body 中的内联 base64 媒体被替换为字符串占位符 [_media_meta mime=... size_bytes=N sha256_hex=...]。 占位符保持原始 JSON 类型(string 仍为 string),避免审计日志中出现 与真实请求结构不一致的 object 字段。
  • 流式响应额外尝试把上游 SSE 合并为结构化 JSON 存入 sse_parsed_json

覆盖的上游路径

所有 5 个协议执行函数(chat completions、anthropic messages、embeddings、 responses、gemini)的 stream 与 non-stream 分支共 9 个发送点,统一通过 finalize_egress() + client.execute() 捕获 egress header,保证规范一致。

Payload 归档到对象存储

TIYGATE_PAYLOAD_ARCHIVE_ENABLED=true 且 S3-Compatible 配置完整时, 服务会启动后台归档 worker,批量扫描 request_payloads 中未归档或可重试的 历史记录。worker 会将完整链路详情拆成 8 个对象:cg_req_raw/cg_req_parsed (Client → Gateway 请求)、gp_req_raw/gp_req_parsed(Gateway → Provider 请求)、 pg_rsp_raw/pg_rsp_parsed(Provider → Gateway 响应)以及 gc_rsp_raw/gc_rsp_parsed(Gateway → Client 响应)。对象 key 规则为 {request_id}/{kind}.{txt|json},并在配置了 TIYGATE_PAYLOAD_ARCHIVE_S3_PREFIX 时追加前缀;其中 cg_req_raw 和所有 *_parsed 使用 JSON,其余 raw body 使用文本。 *_parsed 对象会包含该段已脱敏 headers,并可包含 method、path、status 或 SSE parsed body。 上传内容统一 gzip 压缩,S3 metadata 与 DB manifest 会记录 sha256、原始大小、 压缩后大小、content_typecontent_encoding=gzip

归档状态字段位于 request_payloadspendingarchive_readyuploadinguploadedfailedexpireddisabled。当前捕获行在 OltpSink::write_capture() 完成原文、脱敏正文以及流式响应的 sse_parsed_json/client_sse_parsed_json 最佳努力解析写入后,以 archive_ready 状态对归档 worker 可见;历史 pending 记录会由迁移回填为 archive_ready。归档 worker 只领取 archive_ready(以及锁超时的 uploading)记录,领取后置为 uploading。上传失败会按 TIYGATE_PAYLOAD_ARCHIVE_MAX_RETRIES 重试,未超限时回到 archive_ready,超过后 标记 failed,此时详情接口仍从 DB 原文字段读取;上传成功后才会在同一事务中 清空 request_payloads 中的详情正文与 payload headers,并清空 request_logs 中的 raw_envelope_json/redacted_headers_json,最后写入 payload_archive_manifest_json 并置为 uploaded。详情查询保持 后端代理模式:uploaded 记录由 Admin API 从对象存储读取、校验 sha256/size、 解压后再返回给前端;如果对象上传成功但 DB finalize 提交失败,记录会保持 uploading,后续归档 pass 会按 stale lock 重新领取并以相同对象 key 幂等重试。DB 日志 retention 只清理数据库记录,不删除对象存储文件, 对象生命周期应由 bucket lifecycle 或运维策略管理。

环境变量示例见 .env.example 中的 TIYGATE_PAYLOAD_ARCHIVE_* 配置项。

Header 透传策略(双向 denylist)

网关默认转发 header,只挡黑名单(denylist 模式),策略实现于 crates/core/src/header_forward.rsHeaderForwardPolicy,与脱敏 Redactor 解耦:转发策略决定 header 是否真正上/下线,脱敏决定 header 值在日志里是否被掩码。

请求方向(C→G→P)

merge_client_headers()crates/server/src/ingress.rs)在 upstream_headers 初始化之后、apply_provider_auth() 之前,把客户端请求 header 按 should_forward_request 合并进上游请求;已被 codec 设置的 header 不被覆盖,auth 注入始终最后胜出。默认不转发的请求 header:

  • 凭证类(客户端对网关的凭证,绝不能泄露给 Provider,且网关注入自己的): authorizationproxy-authorizationx-api-keyanthropic-versioncookie
  • 网关重算/自控类:hostcontent-lengthcontent-typecontent-encodingaccept-encodingexpect
  • 逐跳 header(RFC 7230 §6.1):connectionkeep-aliveproxy-connectiontetrailertransfer-encodingupgrade
  • trace(网关重新注入):traceparenttracestate

其余 header(如 x-debug-idx-correlation-id)默认转发给 Provider,并如实 出现在 g→p 段记录中。

响应方向(P→G→C)

forward_upstream_resp_headers() 把供应商响应 header 按 should_forward_response 转发到客户端响应(stream 与 non-stream 均覆盖)。 默认不转发的响应 header:

  • 逐跳 header:connectionkeep-aliveproxy-connectiontetrailertransfer-encodingupgrade
  • 长度/编码(网关重新序列化或 reqwest 解压后失配):content-lengthcontent-encoding
  • 框架自设:content-type(由 Json/Sse 决定)、date

retry-afterx-ratelimit-* 不在黑名单,正常转发给客户端。其余供应商 header(如 x-llm-served-by)默认转发,并如实出现在 g→c 段记录中。

例外:x-request-id 会被改写覆盖为网关为该请求生成的 request id(即 request_iduuid::Uuid::now_v7()),覆盖供应商返回的同名值。该改写对 stream 与 non-stream 均生效,且 client_resp_headers 记录与实际下行一致。

可配置追加

在硬编码默认黑名单之上,可通过环境变量追加额外要拦截的 header(逗号分隔, 大小写不敏感):

  • TIYGATE_FORWARD_REQUEST_HEADER_DENY —— 追加请求方向黑名单
  • TIYGATE_FORWARD_RESPONSE_HEADER_DENY —— 追加响应方向黑名单

例如 TIYGATE_FORWARD_REQUEST_HEADER_DENY=x-stainless-lang,x-internal 会在 默认基础上额外屏蔽这两个客户端 header。