diff --git a/docs/changelog/index.md b/docs/changelog/index.md index c26c23c1..df0bd5eb 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -19,6 +19,7 @@ sidebar: false ### ✨ 新功能 +- OpenTelemetry 链路追踪新增 OTLP/HTTP protobuf 导出,通过标准环境变量配置接收端地址和自定义请求头,保留原有 OTLP/gRPC 默认模式;未支持的协议在启动时明确报错。 - Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。 ## [v3.5.9] - 2026-11-07 diff --git a/docs/deployment/server.md b/docs/deployment/server.md index 9a7d7206..3e1fb3b8 100644 --- a/docs/deployment/server.md +++ b/docs/deployment/server.md @@ -272,6 +272,22 @@ docker compose up -d ``` 启动后可以通过访问 `http://localhost:16686` 打开 Jaeger 监控端查看系统 Span 链路。 +### 自定义 OpenTelemetry 接收端 + +链路追踪默认使用 OTLP/gRPC。对于支持 OTLP/HTTP protobuf 的接收端,可在 Server 容器的环境变量中配置: + +```yaml +OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf +OTEL_EXPORTER_OTLP_ENDPOINT: https://collector.example.com +OTEL_SAMPLING_RATE: "0.1" +``` + +标准 SDK 会在通用 `OTEL_EXPORTER_OTLP_ENDPOINT` 的路径后追加 `/v1/traces`;例如上述配置发送到 `https://collector.example.com/v1/traces`。如果接收端使用自定义路径,请设置完整的 `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,SDK 将直接使用该 URL,不再追加路径。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` 优先于通用协议配置。 + +需要鉴权或其他请求头时,使用可选的 `OTEL_EXPORTER_OTLP_HEADERS`,格式为 `key=value,key2=value2`,值按标准进行百分号编码,例如 `X-API-Key=example-key,X-Client-Name=example%20client`。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_HEADERS` 优先于通用请求头配置。应用不预设鉴权方案;实际请求头与凭据由部署环境提供,不要将真实凭据写入仓库。 + +协议、地址和请求头由 OpenTelemetry SDK 按[标准 OTLP 配置](https://opentelemetry.io/docs/specs/otel/protocol/exporter/)处理,不依赖特定部署平台或观测后端。 + --- ## 首次登录 diff --git a/go.mod b/go.mod index 83b89be8..07f5eb72 100644 --- a/go.mod +++ b/go.mod @@ -48,8 +48,10 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/trace v1.45.0 + go.opentelemetry.io/proto/otlp v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.55.0 golang.org/x/image v0.44.0 @@ -57,6 +59,7 @@ require ( golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.6.2 gorm.io/driver/sqlite v1.6.0 @@ -170,7 +173,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect go.opentelemetry.io/otel/log v0.6.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect @@ -183,7 +185,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/grpc v1.83.2 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect diff --git a/go.sum b/go.sum index 5125ed85..4811869a 100644 --- a/go.sum +++ b/go.sum @@ -356,6 +356,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= diff --git a/pkg/trace/trace_provider.go b/pkg/trace/trace_provider.go index 8051f2b8..7ad86c1f 100644 --- a/pkg/trace/trace_provider.go +++ b/pkg/trace/trace_provider.go @@ -6,10 +6,13 @@ package trace import ( "context" + "fmt" "os" + "strings" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) @@ -37,7 +40,7 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { } // 初始化 Exporter - traceExporter, err := otlptracegrpc.New(context.Background()) + traceExporter, err := newTraceExporter(context.Background()) if err != nil { return nil, err } @@ -50,3 +53,20 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { ) return tracerProvider, nil } + +// newTraceExporter selects the OTLP wire transport without changing existing gRPC defaults. +// The SDK exporters read standard OTEL_EXPORTER_OTLP_* endpoint and header settings. +func newTraceExporter(ctx context.Context) (sdktrace.SpanExporter, error) { + protocol := strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"))) + if protocol == "" { + protocol = strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))) + } + switch protocol { + case "", "grpc": + return otlptracegrpc.New(ctx) + case "http/protobuf": + return otlptracehttp.New(ctx) + default: + return nil, fmt.Errorf("unsupported OTLP trace protocol %q", protocol) + } +} diff --git a/pkg/trace/trace_provider_test.go b/pkg/trace/trace_provider_test.go new file mode 100644 index 00000000..a640b559 --- /dev/null +++ b/pkg/trace/trace_provider_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + collecttrace "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/protobuf/proto" +) + +func TestOTLPHTTPTraceExportHeadersAndResources(t *testing.T) { + type received struct { + headers http.Header + data []byte + path string + method string + } + requests := make(chan received, 1) + responseBody := protoBytes(t, &collecttrace.ExportTraceServiceResponse{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + requests <- received{headers: r.Header.Clone(), data: body, path: r.URL.Path, method: r.Method} + w.Header().Set("Content-Type", "application/x-protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(responseBody) + })) + defer server.Close() + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", server.URL) + t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "X-API-Key=test-key,X-Client-Name=test%20client") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.namespace=example,deployment.environment.name=test") + + tests := []struct { + name string + env map[string]string + wantPath string + wantHeaders map[string]string + }{ + { + name: "base_endpoint", + wantPath: "/v1/traces", + wantHeaders: map[string]string{"X-API-Key": "test-key", "X-Client-Name": "test client"}, + }, + { + name: "base_endpoint_path", + env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL + "/collector"}, + wantPath: "/collector/v1/traces", + wantHeaders: map[string]string{"X-API-Key": "test-key", "X-Client-Name": "test client"}, + }, + { + name: "trace_specific_overrides", + env: map[string]string{ + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": server.URL + "/custom/ingest", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS": "X-API-Key=trace-key,X-Client-Name=trace%20client", + }, + wantPath: "/custom/ingest", + wantHeaders: map[string]string{"X-API-Key": "trace-key", "X-Client-Name": "trace client"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for key, value := range tt.env { + t.Setenv(key, value) + } + provider, err := newTracerProvider(Config{AppName: "openflare-control-plane", SamplingRate: 1}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = provider.Shutdown(context.Background()) }() + _, span := provider.Tracer("test").Start(context.Background(), "canary") + span.End() + if err := provider.ForceFlush(context.Background()); err != nil { + t.Fatal(err) + } + select { + case req := <-requests: + for key, want := range tt.wantHeaders { + if got := req.headers.Get(key); got != want { + t.Errorf("OTLP request header %q = %q, want %q", key, got, want) + } + } + if req.method != "POST" || req.path != tt.wantPath { + t.Errorf("OTLP request = %s %s, want POST %s", req.method, req.path, tt.wantPath) + } + var payload collecttrace.ExportTraceServiceRequest + if err := proto.Unmarshal(req.data, &payload); err != nil { + t.Fatal(err) + } + if len(payload.ResourceSpans) == 0 { + t.Fatal("missing resource spans") + } + attrs := make(map[string]string) + for _, a := range payload.ResourceSpans[0].Resource.Attributes { + attrs[a.Key] = a.Value.GetStringValue() + } + for k, v := range map[string]string{"service.name": "openflare-control-plane", "service.namespace": "example", "deployment.environment.name": "test"} { + if attrs[k] != v { + t.Errorf("resource attribute %s mismatch: got %q", k, attrs[k]) + } + } + default: + t.Fatal("OTLP HTTP request was not received") + } + }) + } +} + +func protoBytes(t *testing.T, v proto.Message) []byte { + t.Helper() + data, err := proto.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestUnknownOTLPProtocolFails(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "") + _, err := newTraceExporter(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unsupported OTLP") { + t.Fatalf("expected protocol error, got %v", err) + } +}