From 9665fb59745cd71ec4939badd17519c0e65f5967 Mon Sep 17 00:00:00 2001 From: k2safe Date: Tue, 6 Oct 2026 21:21:35 +0800 Subject: [PATCH] feat(telemetry): bridge configurable gateway trace headers --- ADVANCED.md | 14 ++ CHANGELOG.md | 7 + DEVELOPMENT.md | 2 +- PROGRAM.md | 2 +- README.md | 16 ++- RELEASE.md | 25 ++++ cmd/go-omni/README.md | 6 +- cmd/go-omni/apigen/generate.go | 2 +- cmd/go-omni/projectgen/generate.go | 2 +- cmd/go-omni/scaffold/version.go | 2 +- examples/mixedWorkspace/README.md | 4 +- examples/recursiveResponse/README.md | 2 +- examples/telemetryHeaders/README.md | 11 ++ examples/telemetryHeaders/main.go | 62 +++++++++ examples/telemetryHeaders/main_test.go | 9 ++ reports/headerPropagationAcceptance.md | 24 ++++ scripts/verifyManagedRelease.py | 8 ++ telemetry/headers.go | 173 +++++++++++++++++++++++++ telemetry/telemetry.go | 25 +++- telemetry/telemetry_test.go | 113 ++++++++++++++++ 20 files changed, 484 insertions(+), 25 deletions(-) create mode 100644 examples/telemetryHeaders/README.md create mode 100644 examples/telemetryHeaders/main.go create mode 100644 examples/telemetryHeaders/main_test.go create mode 100644 reports/headerPropagationAcceptance.md create mode 100644 telemetry/headers.go diff --git a/ADVANCED.md b/ADVANCED.md index 1c3ffce..36ecb58 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -123,6 +123,20 @@ log: `telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。 +上游网关如果使用自定义 Header,可配置通用兼容传播器,不在业务 Handler 里解析 Header: + +```go +propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{ + TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id", +}) +if err != nil { return err } +runtime, err := telemetry.New(telemetry.Config{ + Service: "billing", SampleRatio: 1, Propagator: propagator, +}) +``` + +`HeaderPropagator` 同时支持 W3C `traceparent`;两者同时存在时优先 W3C。有效的兼容 Header 会成为远程父 Span,当前服务仍生成自己的 Span ID,`logx.TraceID` / `RequestID`、访问日志和审计会沿用上下文,出站 `telemetry.Transport` 会同时传播 W3C 和配置的兼容 Header。自定义 Header 不携带采样标志,适配器默认把有效成对 ID 当作已采样;需要保留上游采样决定时使用 W3C。只有受信网关入口才应启用该适配器,Trace/Span ID 不是认证、租户或幂等凭据。完整离线示例见 [telemetryHeaders](examples/telemetryHeaders/README.md)。 + 创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。 ## 分布式限流与幂等 diff --git a/CHANGELOG.md b/CHANGELOG.md index 566688c..5f5a64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# v0.30.0-preview + +- 新增通用 `telemetry.HeaderPropagator` 与 `HeaderPropagationConfig`,配置化接入 Trace ID、Span ID、Request ID Header;不绑定任何网关名称。 +- 入站优先使用标准 W3C `traceparent`;缺少有效 W3C 父链路时,合法的兼容 Header 对成为远程父 Span。日志、审计、访问日志沿用 request/trace 上下文。 +- 出站 `telemetry.Transport` 同时传播 W3C 和配置的兼容 Header;旧配置不变,默认仍只使用 W3C。 +- 补充自定义 Header、W3C 优先级、非法 ID、父子 Span 和通用离线示例回归;不引入业务类型、网关名称或第三方依赖。 + # v0.29.0-preview - 新增可选 `storage.ReadPresigner` / `ReadOptions` / `ReadLink`,签名读取可选择 inline/attachment、显示文件名、MIME 类型,返回签名内的实际到期时间。原 Store/Presigner 和旧下载链接兼容,TTL 上限仍为一小时。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 856e942..cf16db6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.29.0-preview) +# 通用业务开发指南(v0.30.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 diff --git a/PROGRAM.md b/PROGRAM.md index d5e350b..ab37045 100644 --- a/PROGRAM.md +++ b/PROGRAM.md @@ -5,7 +5,7 @@ ## 创建与运行 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview go-omni project new demo --module example.com/demo cd demo go mod tidy diff --git a/README.md b/README.md index fc39ad1..4710e07 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `proj **v0.29 改动**:通用签名读取新增 inline/attachment、中文显示文件名、可选 Content-Type 与实际到期时间;Local、S3/R2、OSS 使用同一接口,原下载接口兼容。本地支持 HEAD、Range、206/416,所有响应选项参与签名。见 [存储说明](storage/README.md#签名预览与下载)及[可运行 PDF 示例](examples/signedRead/README.md)。 +**v0.30 改动**:telemetry 新增配置化 `HeaderPropagator`,兼容网关传入的 Trace/Span/Request Header,同时保留 W3C `traceparent`;入站父链路、日志关联和出站传播统一处理。框架不绑定任何网关名称。见[可运行示例](examples/telemetryHeaders/README.md)和[观测说明](ADVANCED.md#熔断与观测)。 + **v0.28.2 改动**:API 响应 DTO 支持 `Children []Node`、`Next *Node`、递归 map 和相互引用,生成强类型 Go 字段及有限的 OpenAPI `$ref` 图。字段约束、示例校验、生成与漂移检查继续有效;请求递归和非法值递归仍会报错。见 [可运行示例](examples/recursiveResponse/README.md)与[接入说明](DEVELOPMENT.md#递归响应-dto)。 **v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。 @@ -12,9 +14,9 @@ GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `proj **v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。 -**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.29.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 +**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.30.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 -**从这份 README 开始接入。** 当前版本 **`v0.29.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**从这份 README 开始接入。** 当前版本 **`v0.30.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。 @@ -71,7 +73,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) | | 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) | | 文件存储、签名预览/下载、直传与云端分片 | Store 的 Put/Get/Delete;ReadPresigner 签名响应选项;Local、S3/R2、OSS 按配置装配 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) | -| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) | +| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader;`HeaderPropagator` 兼容自定义网关 Header | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[Header 示例](examples/telemetryHeaders/README.md) | | 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) | | 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) | | 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) | @@ -79,7 +81,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 ## 通用工程快速开始 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview go-omni project new demo --module example.com/demo cd demo go mod tidy @@ -184,7 +186,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -341,7 +343,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.29.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -672,7 +674,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.29.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.30.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index b71e176..45f04c7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,28 @@ +# v0.30.0-preview + +新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。 + +W3C `traceparent` 始终优先于兼容 Header。兼容 Header 只接受合法的 32 位 Trace ID 和 16 位 Span ID,当前服务生成自己的 Span ID;Request ID 单独传播,不参与父 Span。旧项目不配置新传播器时,默认行为不变。兼容 Header 不携带采样标志,有效成对值按已采样处理;需要传递采样决定时使用 W3C。 + +```go +propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{ + TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id", +}) +if err != nil { return err } +runtime, err := telemetry.New(telemetry.Config{ + Service: "billing", SampleRatio: 1, Propagator: propagator, +}) +``` + +只在受信网关入口启用兼容传播;Trace/Span ID 不是认证、租户或幂等凭据。完整 [接入说明](ADVANCED.md#熔断与观测)、[离线示例](examples/telemetryHeaders/README.md)。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview +go test -race ./telemetry ./examples/telemetryHeaders +``` + +--- + # v0.29.0-preview 新增通用签名读取选项:预览或下载、显示文件名和 MIME 类型,由调用方选择。`PresignGetWithOptions` 返回 URL 与签名内的到期时间;原 `PresignGet` 默认下载、第三方原有接口和旧本地链接继续兼容。有效期仍是 1 秒到 1 小时,不增加撤销或业务分享机制。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 410af90..9fb0c38 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -57,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.29.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.30.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -当前模板与依赖以 v0.29.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +当前模板与依赖以 v0.30.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index ee3c104..5c092e8 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Runtime-only releases need not raise the managed template compatibility floor. const minimumVersion = "v0.22.0-preview" -const currentVersion = "v0.29.0-preview" +const currentVersion = "v0.30.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { diff --git a/cmd/go-omni/projectgen/generate.go b/cmd/go-omni/projectgen/generate.go index 2489e97..28b232d 100644 --- a/cmd/go-omni/projectgen/generate.go +++ b/cmd/go-omni/projectgen/generate.go @@ -25,7 +25,7 @@ import ( ) const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" -const currentVersion = "v0.29.0-preview" +const currentVersion = "v0.30.0-preview" const minimumVersion = "v0.28.0-preview" const manifestPath = ".goOmni/projectManifest.json" const lockPath = ".goOmni/project.lock" diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index d25812f..3dbe5eb 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.29.0-preview" +const defaultFrameworkVersion = "v0.30.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/examples/mixedWorkspace/README.md b/examples/mixedWorkspace/README.md index d32f5b0..de2e26c 100644 --- a/examples/mixedWorkspace/README.md +++ b/examples/mixedWorkspace/README.md @@ -1,11 +1,11 @@ # API 与通用工程混合工作区 -本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.29.0-preview 或更新版本 CLI。 +本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.30.0-preview 或更新版本 CLI。 下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。 ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview mkdir mixed-demo cd mixed-demo go-omni api new apps/interface --module example.com/interface diff --git a/examples/recursiveResponse/README.md b/examples/recursiveResponse/README.md index 0396442..75f86c5 100644 --- a/examples/recursiveResponse/README.md +++ b/examples/recursiveResponse/README.md @@ -5,7 +5,7 @@ 在 go-omni 仓库根目录执行(新建独立临时目录): ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview example_dir="$(mktemp -d)/recursive" go-omni api generate --api examples/recursiveResponse/service.api --dir "$example_dir" --module example.com/recursive # 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。 diff --git a/examples/telemetryHeaders/README.md b/examples/telemetryHeaders/README.md new file mode 100644 index 0000000..2e88cf4 --- /dev/null +++ b/examples/telemetryHeaders/README.md @@ -0,0 +1,11 @@ +# 配置化网关 Trace Header + +```sh +go run ./examples/telemetryHeaders +``` + +这个离线示例使用标准库 `httptest`,不连接网关、数据库或外部 Collector。它把 `X-Trace-Id`、`X-Span-Id` 和 `X-Request-Id` 配置给通用 `telemetry.HeaderPropagator`,再通过 `app.Use(runtime.Middleware())` 进入服务。业务 Handler 只读取 `logx.TraceID` / `logx.RequestID`,不解析 Header。 + +`HeaderPropagator` 同时支持标准 W3C `traceparent`;两种格式同时存在时优先 W3C。出站请求使用 `telemetry.Transport` 时会注入 W3C 和配置的兼容 Header。旧项目不配置它时,go-omni 仍只使用默认 W3C 传播。 + +示例中的 ID 是固定测试值。生产环境只有在网关或受信代理已经校验这些 Header 时才启用兼容传播;Trace ID/Span ID 不是认证凭据,也不能用于授权、幂等或租户隔离。自定义 Header 不携带采样标志,适配器会把有效的成对 ID 视为已采样;需要保留上游采样决定时应使用 `traceparent`。 diff --git a/examples/telemetryHeaders/main.go b/examples/telemetryHeaders/main.go new file mode 100644 index 0000000..55ca759 --- /dev/null +++ b/examples/telemetryHeaders/main.go @@ -0,0 +1,62 @@ +// telemetryHeaders demonstrates a provider-neutral compatibility boundary for +// gateways that carry trace IDs in configurable HTTP headers. +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/logx" + "git.yuntuo.hk/k2safe/go-omni/telemetry" +) + +func main() { + if err := run(); err != nil { + panic(err) + } +} + +func run() error { + propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{ + TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id", + }) + if err != nil { + return err + } + runtime, err := telemetry.New(telemetry.Config{Service: "header-example", SampleRatio: 1, Propagator: propagator}) + if err != nil { + return err + } + defer runtime.Close() + app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id")) + defer app.Close() + app.Use(runtime.Middleware()) + app.GET("/", func(c *omni.Context) error { + fmt.Printf("traceId=%s requestId=%s\n", logx.TraceID(c.Context()), logx.RequestID(c.Context())) + return c.OK(map[string]string{"traceId": logx.TraceID(c.Context()), "requestId": logx.RequestID(c.Context())}) + }) + server := httptest.NewServer(app) + defer server.Close() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil) + if err != nil { + return err + } + req.Header.Set("X-Trace-Id", "11111111111111111111111111111111") + req.Header.Set("X-Span-Id", "2222222222222222") + req.Header.Set("X-Request-Id", "example-request") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + fmt.Printf("status=%d response=%s", resp.StatusCode, body) + return nil +} diff --git a/examples/telemetryHeaders/main_test.go b/examples/telemetryHeaders/main_test.go new file mode 100644 index 0000000..f93fbc2 --- /dev/null +++ b/examples/telemetryHeaders/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestTelemetryHeadersExample(t *testing.T) { + if err := run(); err != nil { + t.Fatal(err) + } +} diff --git a/reports/headerPropagationAcceptance.md b/reports/headerPropagationAcceptance.md new file mode 100644 index 0000000..40d2d0e --- /dev/null +++ b/reports/headerPropagationAcceptance.md @@ -0,0 +1,24 @@ +# Header 传播适配验收 + +## 范围 + +本次为 go-omni 增加通用 `telemetry.HeaderPropagator`。调用方配置 Trace ID、Span ID 和可选 Request ID Header 后,框架负责入站父链路、日志关联、访问日志和出站传播;框架不识别任何网关名称,也不解析业务 Header。 + +标准 W3C `traceparent` 与兼容 Header 同时出现时优先 W3C。兼容 Header 必须是合法的 32 位 Trace ID 和 16 位 Span ID;旧 Header 没有采样位,合法成对值按已采样处理。Request ID 只用于关联,不参与 Span 父子关系。兼容传播只应放在已校验 Header 的受信入口。 + +## 验收结果 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:通过,包含生成器、生命周期、存储、i18n、telemetry 和离线示例。 +- `go test -race ./telemetry ./examples/telemetryHeaders`:通过。 +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 GOTOOLCHAIN=go1.22.12 go test -count=1 ./...`:通过。 +- `go vet ./...`、`go build ./...`、`git diff --check`:通过。 +- `go run ./examples/telemetryHeaders`:通过,示例打印固定 Trace ID、Request ID 和 JSON 响应。 + +完整竞态套件在高并发生成器生命周期用例中出现了已有的时序超时(`TestGeneratedMainLifecycle/drainBeforeClose`、`TestGeneratedBackgroundPhases/nethttp`);单独重跑受影响的 `TestManagedGeneratedProject` 通过,功能定向竞态测试通过,未发现本次 Header 传播相关竞态。 + +## 代码边界 + +- 默认 `telemetry.Config` 仍使用 W3C 传播,未配置新适配器的项目行为不变。 +- `telemetry.Transport` 在兼容模式下同时注入 W3C 与配置 Header,调用方请求对象不会被修改。 +- 无效 ID、重复 Header 名称以及试图覆盖 `traceparent` / `tracestate` 的配置会被拒绝或忽略。 +- 不增加认证、租户、幂等、业务重试或网关依赖。 diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index 6878b8e..4b213bf 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -172,6 +172,8 @@ def main(): "storage/oss": {"TestReadOptionsNativeSignatures"}, "storage/s3": {"TestReadOptionsS3AndR2"}, "examples/signedRead": {"TestSignedReadHTTP"}, + "telemetry": {"TestConfiguredHeaderPropagation", "TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup"}, + "examples/telemetryHeaders": {"TestTelemetryHeadersExample"}, "copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"}, "logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"}, "cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"}, @@ -196,6 +198,10 @@ def main(): checks += ["remoteSignedReadOptionsAndLegacyLinks", "remoteReadSignatureTamperingAndExpiry", "remoteLocalPayloadRangesAndHEAD", "remoteOSSNativeV1V4Signatures", "remoteS3R2ReadOptions", "remoteSignedReadHTTPExample"] + header_output = run(["go", "run", MODULE + "/examples/telemetryHeaders"], starter, env) + assert "traceId=11111111111111111111111111111111" in header_output + checks += ["remoteConfiguredHeaderPropagation", "remoteHeaderW3CPrecedenceAndValidation", + "remoteHeaderTelemetryExample"] checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup", "remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation", "remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"] @@ -264,6 +270,8 @@ def main(): "transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "storage").rglob("*.go")] sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/signedRead").glob("*.go")] + sources += [str(p.relative_to(ROOT)) for p in (ROOT / "telemetry").glob("*.go")] + sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/telemetryHeaders").glob("*.go")] report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], "freshModuleCache": True, "noLocalReplace": True, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks, diff --git a/telemetry/headers.go b/telemetry/headers.go new file mode 100644 index 0000000..f51c28a --- /dev/null +++ b/telemetry/headers.go @@ -0,0 +1,173 @@ +package telemetry + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "unicode" + + "git.yuntuo.hk/k2safe/go-omni/logx" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// HeaderPropagationConfig maps a legacy or gateway header pair to the +// standard OpenTelemetry trace context. TraceIDHeader and SpanIDHeader must +// carry a 32- and 16-hexadecimal ID respectively. RequestIDHeader is optional +// and is copied to/from the request correlation context. +// +// Legacy headers do not carry a sampling flag. The adapter treats a valid pair +// as sampled so the trace remains visible to the configured exporter. Services +// that need upstream sampling decisions should use the standard traceparent +// header, which always takes precedence. +type HeaderPropagationConfig struct { + TraceIDHeader string `json:"traceIdHeader"` + SpanIDHeader string `json:"spanIdHeader"` + RequestIDHeader string `json:"requestIdHeader,omitempty"` +} + +// HeaderPropagator accepts standard W3C trace context and one configured +// legacy header pair. W3C context wins when both formats are present. It also +// propagates the configured request ID without making it part of the span +// parent. Use it only at a boundary that is authorized to supply these IDs. +type HeaderPropagator struct { + config HeaderPropagationConfig + trace propagation.TraceContext +} + +// NewHeaderPropagator creates a generic compatibility propagator. Header names +// are canonicalized and compared case-insensitively. It does not trust or +// inspect a Host header; the caller decides which ingress is trusted. +func NewHeaderPropagator(config HeaderPropagationConfig) (*HeaderPropagator, error) { + traceHeader, err := canonicalPropagationHeader(config.TraceIDHeader) + if err != nil { + return nil, errors.Join(errors.New("telemetry: invalid trace ID header"), err) + } + spanHeader, err := canonicalPropagationHeader(config.SpanIDHeader) + if err != nil { + return nil, errors.Join(errors.New("telemetry: invalid span ID header"), err) + } + requestHeader := "" + if strings.TrimSpace(config.RequestIDHeader) != "" { + requestHeader, err = canonicalPropagationHeader(config.RequestIDHeader) + if err != nil { + return nil, errors.Join(errors.New("telemetry: invalid request ID header"), err) + } + } + if strings.EqualFold(traceHeader, spanHeader) || + (requestHeader != "" && (strings.EqualFold(traceHeader, requestHeader) || strings.EqualFold(spanHeader, requestHeader))) { + return nil, errors.New("telemetry: propagation headers must be distinct") + } + for _, name := range []string{traceHeader, spanHeader, requestHeader} { + if strings.EqualFold(name, "Traceparent") || strings.EqualFold(name, "Tracestate") { + return nil, errors.New("telemetry: custom headers cannot replace W3C trace context") + } + } + config.TraceIDHeader = traceHeader + config.SpanIDHeader = spanHeader + config.RequestIDHeader = requestHeader + return &HeaderPropagator{config: config}, nil +} + +func canonicalPropagationHeader(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("header name is required") + } + canonical := http.CanonicalHeaderKey(name) + if canonical == "" || strings.IndexFunc(name, unicode.IsSpace) >= 0 || strings.ContainsAny(name, "\r\n:") { + return "", errors.New("header name is malformed") + } + return canonical, nil +} + +// Inject writes W3C trace context first and then the configured compatibility +// headers. Existing caller values are replaced so all propagated IDs describe +// the same current span. +func (p *HeaderPropagator) Inject(ctx context.Context, carrier propagation.TextMapCarrier) { + if p == nil || carrier == nil { + return + } + p.trace.Inject(ctx, carrier) + sc := trace.SpanContextFromContext(ctx) + if sc.IsValid() { + carrier.Set(p.config.TraceIDHeader, sc.TraceID().String()) + carrier.Set(p.config.SpanIDHeader, sc.SpanID().String()) + } + if p.config.RequestIDHeader != "" { + if requestID := validCorrelationID(logx.RequestID(ctx)); requestID != "" { + carrier.Set(p.config.RequestIDHeader, requestID) + } + } +} + +// Extract prefers a valid W3C parent. If it is absent or malformed, a valid +// configured legacy pair becomes a remote parent. Invalid legacy IDs are +// ignored, never copied into logs or a new outbound request. +func (p *HeaderPropagator) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context { + if p == nil || carrier == nil { + return ctx + } + ctx = p.trace.Extract(ctx, carrier) + if p.config.RequestIDHeader != "" { + if requestID := validCorrelationID(carrierValue(carrier, p.config.RequestIDHeader)); requestID != "" { + ctx = logx.WithRequestID(ctx, requestID) + } + } + if trace.SpanContextFromContext(ctx).IsValid() { + return ctx + } + traceID, err := trace.TraceIDFromHex(carrierValue(carrier, p.config.TraceIDHeader)) + if err != nil || !traceID.IsValid() { + return ctx + } + spanID, err := trace.SpanIDFromHex(carrierValue(carrier, p.config.SpanIDHeader)) + if err != nil || !spanID.IsValid() { + return ctx + } + return trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, SpanID: spanID, TraceFlags: trace.FlagsSampled, Remote: true, + })) +} + +// Fields reports both W3C and compatibility fields in stable order. +func (p *HeaderPropagator) Fields() []string { + if p == nil { + return nil + } + fields := []string{"traceparent", "tracestate", p.config.TraceIDHeader, p.config.SpanIDHeader} + if p.config.RequestIDHeader != "" { + fields = append(fields, p.config.RequestIDHeader) + } + sort.Strings(fields) + return fields +} + +func carrierValue(carrier propagation.TextMapCarrier, name string) string { + if value := carrier.Get(name); value != "" { + return value + } + for _, key := range carrier.Keys() { + if strings.EqualFold(key, name) { + return carrier.Get(key) + } + } + return "" +} + +func validCorrelationID(value string) string { + if value == "" || value != strings.TrimSpace(value) || len(value) > 128 { + return "" + } + for _, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' || r == ':' { + continue + } + return "" + } + return value +} + +var _ propagation.TextMapPropagator = (*HeaderPropagator)(nil) diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index e9b6e1c..6dbcfe3 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -21,10 +21,11 @@ import ( ) type Config struct { - Service string `json:"service"` - SampleRatio float64 `json:"sampleRatio"` - Exporter sdktrace.SpanExporter `json:"-"` - Reader sdkmetric.Reader `json:"-"` + Service string `json:"service"` + SampleRatio float64 `json:"sampleRatio"` + Exporter sdktrace.SpanExporter `json:"-"` + Reader sdkmetric.Reader `json:"-"` + Propagator propagation.TextMapPropagator `json:"-"` } type Runtime struct { traces *sdktrace.TracerProvider @@ -48,7 +49,11 @@ func New(cfg Config) (*Runtime, error) { if cfg.Reader != nil { metricOptions = append(metricOptions, sdkmetric.WithReader(cfg.Reader)) } - r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagation.TraceContext{}} + propagator := cfg.Propagator + if propagator == nil { + propagator = propagation.TraceContext{} + } + r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagator} r.tracer = r.traces.Tracer("git.yuntuo.hk/k2safe/go-omni") meter := r.meters.Meter("git.yuntuo.hk/k2safe/go-omni") r.duration, _ = meter.Float64Histogram("http.server.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10)) @@ -121,8 +126,14 @@ func (r *Runtime) Middleware() httpx.Middleware { r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...)) span.End() // Preserve correlation for outer access-log middleware without - // exposing an ended span as its active span. - c.WithContext(logx.WithTraceID(parent, span.SpanContext().TraceID().String())) + // exposing an ended span as its active span. A custom propagator may + // have supplied a request ID that the built-in RequestID middleware + // did not know about, so carry that value back as well. + outer := logx.WithTraceID(parent, span.SpanContext().TraceID().String()) + if requestID := logx.RequestID(ctx); requestID != "" { + outer = logx.WithRequestID(outer, requestID) + } + c.WithContext(outer) if p != nil { panic(p) } diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go index 8efdafc..fd58df8 100644 --- a/telemetry/telemetry_test.go +++ b/telemetry/telemetry_test.go @@ -7,6 +7,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/telemetry" "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel/propagation" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" "go.opentelemetry.io/otel/sdk/trace/tracetest" @@ -115,3 +116,115 @@ func TestHTTPTracePropagationMetricsAndRedisNil(t *testing.T) { t.Fatal("request metric", count) } } + +func TestConfiguredHeaderPropagation(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{ + TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id", + }) + if err != nil { + t.Fatal(err) + } + runtime, err := telemetry.New(telemetry.Config{Service: "header-test", SampleRatio: 1, Exporter: exporter, Propagator: propagator}) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + const traceID = "11111111111111111111111111111111" + const parentSpanID = "2222222222222222" + const requestID = "gateway-request-1" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Trace-Id") != traceID || r.Header.Get("X-Request-Id") != requestID { + t.Errorf("custom propagation = trace %q request %q", r.Header.Get("X-Trace-Id"), r.Header.Get("X-Request-Id")) + } + if r.Header.Get("X-Span-Id") == parentSpanID || len(r.Header.Get("X-Span-Id")) != 16 || !strings.HasPrefix(r.Header.Get("Traceparent"), "00-"+traceID+"-") { + t.Errorf("outbound parent propagation = %q", r.Header) + } + io.WriteString(w, "ok") + })) + defer upstream.Close() + app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id")) + defer app.Close() + app.Use(runtime.Middleware()) + app.GET("/", func(c *httpx.Context) error { + if got := logx.TraceID(c.Context()); got != traceID { + t.Errorf("inbound trace correlation = %q", got) + } + if got := logx.RequestID(c.Context()); got != requestID { + t.Errorf("inbound request correlation = %q", got) + } + req, _ := http.NewRequestWithContext(c.Context(), http.MethodGet, upstream.URL, nil) + resp, err := (telemetry.Transport{Runtime: runtime}).RoundTrip(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + return c.OK("ok") + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Trace-Id", traceID) + req.Header.Set("X-Span-Id", parentSpanID) + req.Header.Set("X-Request-Id", requestID) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Header().Get("X-Request-Id") != requestID { + t.Fatalf("response status/request ID = %d/%q", rec.Code, rec.Header().Get("X-Request-Id")) + } + if err := runtime.Flush(context.Background()); err != nil { + t.Fatal(err) + } + spans := exporter.GetSpans() + var server, client *tracetest.SpanStub + for _, span := range spans { + switch span.SpanKind { + case trace.SpanKindServer: + span := span + server = &span + case trace.SpanKindClient: + span := span + client = &span + } + } + if server == nil || client == nil { + t.Fatalf("missing spans: %d", len(spans)) + } + if server.SpanContext.TraceID().String() != traceID || server.Parent.TraceID().String() != traceID || server.Parent.SpanID().String() != parentSpanID || !server.Parent.IsRemote() { + t.Fatalf("server parent = %s/%s remote=%v", server.SpanContext.TraceID(), server.Parent.SpanID(), server.Parent.IsRemote()) + } + if client.SpanContext.TraceID().String() != traceID || client.Parent.SpanID() != server.SpanContext.SpanID() { + t.Fatalf("client linkage = trace %s parent %s", client.SpanContext.TraceID(), client.Parent.SpanID()) + } +} + +func TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup(t *testing.T) { + propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{TraceIDHeader: "x-trace-id", SpanIDHeader: "x-span-id"}) + if err != nil { + t.Fatal(err) + } + carrier := propagation.MapCarrier{ + "traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01", + "X-Trace-Id": "11111111111111111111111111111111", + "X-Span-Id": "2222222222222222", + } + ctx := propagator.Extract(context.Background(), carrier) + parent := trace.SpanContextFromContext(ctx) + if parent.TraceID().String() != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || parent.SpanID().String() != "bbbbbbbbbbbbbbbb" { + t.Fatalf("W3C parent was not preferred: %s/%s", parent.TraceID(), parent.SpanID()) + } + for _, config := range []telemetry.HeaderPropagationConfig{ + {TraceIDHeader: "", SpanIDHeader: "X-Span-Id"}, + {TraceIDHeader: "X-Trace-Id", SpanIDHeader: ""}, + {TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Trace-Id"}, + {TraceIDHeader: "traceparent", SpanIDHeader: "X-Span-Id"}, + {TraceIDHeader: "X Trace", SpanIDHeader: "X-Span-Id"}, + } { + if _, err := telemetry.NewHeaderPropagator(config); err == nil { + t.Fatalf("invalid propagation config accepted: %+v", config) + } + } + invalid := propagation.MapCarrier{"X-Trace-Id": "bad", "X-Span-Id": "bad"} + if got := trace.SpanContextFromContext(propagator.Extract(context.Background(), invalid)); got.IsValid() { + t.Fatal("invalid legacy IDs became a parent") + } +}