mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(otel): support standard OTLP/HTTP trace export (#64)
This commit is contained in:
@@ -95,6 +95,7 @@ worker:
|
||||
|
||||
# ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
|
||||
otel:
|
||||
export_protocol: "grpc" # grpc 或 http/protobuf;OTEL_EXPORTER_OTLP_TRACES_PROTOCOL 优先于通用 OTLP 协议变量
|
||||
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
|
||||
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ sidebar: false
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- OpenTelemetry 链路追踪新增 OTLP/HTTP protobuf 导出,通过标准环境变量配置接收端地址和自定义请求头,协议由统一启动配置解析并支持 `otel.export_protocol`,保留原有 OTLP/gRPC 默认模式;未支持的协议在启动时明确报错。
|
||||
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
|
||||
|
||||
## [v3.5.9] - 2026-11-07
|
||||
|
||||
@@ -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.export_protocol`,缺省为 `grpc`。协议在启动配置层统一解析,再传入追踪组件。
|
||||
|
||||
需要鉴权或其他请求头时,使用可选的 `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/)处理,不依赖特定部署平台或观测后端。
|
||||
|
||||
---
|
||||
|
||||
## 首次登录
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -33,9 +33,10 @@ var rootCmd = &cobra.Command{
|
||||
Compress: config.Config.Log.Compress,
|
||||
})
|
||||
trace.Init(trace.Config{
|
||||
AppName: config.Config.App.AppName,
|
||||
SamplingRate: config.Config.Otel.SamplingRate,
|
||||
TracerName: config.Config.Otel.TracerName,
|
||||
AppName: config.Config.App.AppName,
|
||||
SamplingRate: config.Config.Otel.SamplingRate,
|
||||
TracerName: config.Config.Otel.TracerName,
|
||||
ExportProtocol: config.Config.Otel.ExportProtocol,
|
||||
})
|
||||
},
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
|
||||
@@ -273,6 +273,14 @@ func applyEnvOverrides(c *configModel) {
|
||||
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
|
||||
|
||||
// ─── OTel ───
|
||||
protocol := strings.TrimSpace(envStr("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", ""))
|
||||
if protocol == "" {
|
||||
protocol = strings.TrimSpace(envStr("OTEL_EXPORTER_OTLP_PROTOCOL", ""))
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = c.Otel.ExportProtocol
|
||||
}
|
||||
c.Otel.ExportProtocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
|
||||
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
|
||||
|
||||
|
||||
@@ -47,3 +47,24 @@ func TestApplyClickHouseDefaultsEnablesWhenConfigured(t *testing.T) {
|
||||
t.Fatalf("hosts default not applied: %v", cfg.ClickHouse.Hosts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOTLPProtocolPrecedence covers signal-specific, general, YAML and default settings.
|
||||
func TestOTLPProtocolPrecedence(t *testing.T) {
|
||||
for _, tt := range []struct{ name, signal, general, configured, want string }{
|
||||
{name: "default"},
|
||||
{name: "yaml", configured: "http/protobuf", want: "http/protobuf"},
|
||||
{name: "general", general: "http/protobuf", configured: "grpc", want: "http/protobuf"},
|
||||
{name: "signal", signal: " HTTP/PROTOBUF ", general: "grpc", want: "http/protobuf"},
|
||||
{name: "empty_signal", signal: " ", general: "grpc", configured: "http/protobuf", want: "grpc"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", tt.signal)
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", tt.general)
|
||||
cfg := &configModel{Otel: otelConfig{ExportProtocol: tt.configured}}
|
||||
applyEnvOverrides(cfg)
|
||||
if cfg.Otel.ExportProtocol != tt.want {
|
||||
t.Errorf("OTLP protocol = %q, want %q", cfg.Otel.ExportProtocol, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ type QueueConfig struct {
|
||||
|
||||
// otelConfig OpenTelemetry 配置
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `mapstructure:"sampling_rate"`
|
||||
TracerName string `mapstructure:"tracer_name"`
|
||||
ExportProtocol string `mapstructure:"export_protocol"`
|
||||
SamplingRate float64 `mapstructure:"sampling_rate"`
|
||||
TracerName string `mapstructure:"tracer_name"`
|
||||
}
|
||||
|
||||
+4
-3
@@ -27,9 +27,10 @@ func init() {
|
||||
|
||||
// Config 链路追踪配置
|
||||
type Config struct {
|
||||
AppName string
|
||||
SamplingRate float64
|
||||
TracerName string
|
||||
AppName string
|
||||
SamplingRate float64
|
||||
TracerName string
|
||||
ExportProtocol string
|
||||
}
|
||||
|
||||
// Init 初始化 Tracer Provider 并关联全局 Tracer 实例
|
||||
|
||||
@@ -6,14 +6,18 @@ 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"
|
||||
)
|
||||
|
||||
// newTracerProvider combines SDK and application resources with a batched exporter.
|
||||
func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) {
|
||||
// 获取主机名和容器信息
|
||||
hostname, err := os.Hostname()
|
||||
@@ -37,7 +41,7 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) {
|
||||
}
|
||||
|
||||
// 初始化 Exporter
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
traceExporter, err := newTraceExporter(context.Background(), cfg.ExportProtocol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,3 +54,17 @@ 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, protocol string) (sdktrace.SpanExporter, error) {
|
||||
protocol = strings.ToLower(strings.TrimSpace(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// TestOTLPHTTPTraceExportHeadersAndResources verifies wire payloads against a local collector.
|
||||
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_ENDPOINT", "")
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "")
|
||||
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_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, ExportProtocol: "http/protobuf"})
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// protoBytes encodes a collector response for the HTTP test server.
|
||||
func protoBytes(t *testing.T, v proto.Message) []byte {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestUnknownOTLPProtocolFails rejects unsupported wire formats during initialization.
|
||||
func TestUnknownOTLPProtocolFails(t *testing.T) {
|
||||
_, err := newTraceExporter(context.Background(), "http/json")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported OTLP") {
|
||||
t.Fatalf("expected protocol error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultOTLPProtocolGRPC keeps empty and explicit gRPC configuration working.
|
||||
func TestDefaultOTLPProtocolGRPC(t *testing.T) {
|
||||
for _, protocol := range []string{"", "grpc"} {
|
||||
exporter, err := newTraceExporter(context.Background(), protocol)
|
||||
if err != nil {
|
||||
t.Fatalf("newTraceExporter(%q): %v", protocol, err)
|
||||
}
|
||||
if err := exporter.Shutdown(context.Background()); err != nil {
|
||||
t.Errorf("exporter.Shutdown(%q): %v", protocol, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user