feat(telemetry): bridge configurable gateway trace headers
This commit is contained in:
173
telemetry/headers.go
Normal file
173
telemetry/headers.go
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user