diff --git a/api/internal/httputil/client.go b/api/internal/httputil/client.go index 59db921d..b227ee80 100644 --- a/api/internal/httputil/client.go +++ b/api/internal/httputil/client.go @@ -99,8 +99,11 @@ func wrapTransport(base http.RoundTripper) http.RoundTripper { base = http.DefaultTransport } return &userAgentTransport{ - base: otelhttp.NewTransport(base), - ua: UserAgentString(), + base: otelhttp.NewTransport( + base, + otelhttp.WithSpanNameFormatter(ClientSpanName), + ), + ua: UserAgentString(), } } diff --git a/api/internal/httputil/client_span_name.go b/api/internal/httputil/client_span_name.go new file mode 100644 index 00000000..469ed0c0 --- /dev/null +++ b/api/internal/httputil/client_span_name.go @@ -0,0 +1,190 @@ +package httputil + +import ( + "net" + "net/http" + "strings" + "unicode" +) + +// sharedSpanHosts are outbound peers with stable, deployment-wide cardinality. +// Tenant shop domains, SSO IdPs, and other per-customer hosts are collapsed to +// "{host}" so Datadog resources stay bounded while shared APIs remain readable +// (e.g. "GET api.shopware.com/pluginStore/pluginsByName"). +var sharedSpanHosts = map[string]struct{}{ + "api.shopware.com": {}, + "releases.shopware.com": {}, + "store.shopware.com": {}, + "raw.githubusercontent.com": {}, +} + +// ClientSpanName formats an outbound HTTP client span as "METHOD host/path". +// +// otelhttp's default transport formatter uses only the method ("HTTP GET"), +// which Datadog collapses to a bare GET/POST resource. This formatter keeps +// cardinality low by: +// - stripping query strings +// - keeping only known shared hosts literal; other hosts become "{host}" +// - replacing UUID / numeric / long-hex path segments with "{id}" +// +// The operation argument is ignored; otelhttp's client transport always passes "". +func ClientSpanName(_ string, r *http.Request) string { + if r == nil { + return http.MethodGet + } + + method := r.Method + if method == "" { + method = http.MethodGet + } + + host := spanHost(requestHost(r)) + path := spanPath(requestPath(r)) + + if host == "" { + return method + " " + path + } + if strings.HasPrefix(path, "/") { + return method + " " + host + path + } + return method + " " + host + "/" + path +} + +func requestHost(r *http.Request) string { + if r.URL != nil { + if host := r.URL.Hostname(); host != "" { + return host + } + if host := hostnameOnly(r.URL.Host); host != "" { + return host + } + } + return hostnameOnly(r.Host) +} + +func requestPath(r *http.Request) string { + if r.URL == nil { + return "/" + } + // EscapedPath omits the query string; fall back to Path, then "/". + path := r.URL.EscapedPath() + if path == "" { + path = r.URL.Path + } + if path == "" { + return "/" + } + return path +} + +func hostnameOnly(hostport string) string { + if hostport == "" { + return "" + } + // Strip brackets from IPv6 literals without a port: "[::1]". + if strings.HasPrefix(hostport, "[") && strings.HasSuffix(hostport, "]") { + return hostport[1 : len(hostport)-1] + } + host, _, err := net.SplitHostPort(hostport) + if err != nil { + return hostport + } + return host +} + +func spanHost(host string) string { + if host == "" { + return "" + } + host = strings.ToLower(host) + if _, ok := sharedSpanHosts[host]; ok { + return host + } + return "{host}" +} + +func spanPath(path string) string { + if path == "" || path == "/" { + return "/" + } + + leading := strings.HasPrefix(path, "/") + parts := strings.Split(path, "/") + for i, part := range parts { + if part == "" { + continue + } + if isHighCardinalityPathSegment(part) { + parts[i] = "{id}" + } + } + out := strings.Join(parts, "/") + if leading && !strings.HasPrefix(out, "/") { + return "/" + out + } + return out +} + +func isHighCardinalityPathSegment(seg string) bool { + if isUUIDSegment(seg) { + return true + } + if isAllDigits(seg) { + return true + } + // Long hex tokens (OAuth-ish / opaque IDs), but not short version-like + // fragments such as "v1". + if len(seg) >= 16 && isAllHex(seg) { + return true + } + return false +} + +func isUUIDSegment(seg string) bool { + // 8-4-4-4-12 dashed UUID. + if len(seg) == 36 { + for i, r := range seg { + switch i { + case 8, 13, 18, 23: + if r != '-' { + return false + } + default: + if !isHexRune(r) { + return false + } + } + } + return true + } + // 32-char hex UUID without dashes. + return len(seg) == 32 && isAllHex(seg) +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsDigit(r) { + return false + } + } + return true +} + +func isAllHex(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !isHexRune(r) { + return false + } + } + return true +} + +func isHexRune(r rune) bool { + return unicode.IsDigit(r) || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +} diff --git a/api/internal/httputil/client_span_name_test.go b/api/internal/httputil/client_span_name_test.go new file mode 100644 index 00000000..d7aa11f7 --- /dev/null +++ b/api/internal/httputil/client_span_name_test.go @@ -0,0 +1,136 @@ +package httputil + +import ( + "net/http" + "net/url" + "testing" +) + +func TestClientSpanName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + rawURL string + host string // optional Request.Host override when URL has no host + want string + }{ + { + name: "shared host and path", + method: http.MethodGet, + rawURL: "https://api.shopware.com/pluginStore/pluginsByName", + want: "GET api.shopware.com/pluginStore/pluginsByName", + }, + { + name: "query string stripped", + method: http.MethodGet, + rawURL: "https://api.shopware.com/pluginStore/pluginsByName?locale=en-GB&shopId=abc", + want: "GET api.shopware.com/pluginStore/pluginsByName", + }, + { + name: "missing host falls back to path", + method: http.MethodPost, + rawURL: "/swplatform/autoupdate", + want: "POST /swplatform/autoupdate", + }, + { + name: "empty path on shared host", + method: http.MethodGet, + rawURL: "https://api.shopware.com", + want: "GET api.shopware.com/", + }, + { + name: "tenant host collapsed", + method: http.MethodHead, + rawURL: "https://shop.example.com/", + want: "HEAD {host}/", + }, + { + name: "host from Request.Host when URL host empty", + method: http.MethodPut, + rawURL: "/api/oauth/token", + host: "shop.example:443", + want: "PUT {host}/api/oauth/token", + }, + { + name: "non-shared IP host collapsed and port stripped", + method: http.MethodGet, + rawURL: "http://127.0.0.1:8080/_info/config", + want: "GET {host}/_info/config", + }, + { + name: "uuid path segment grouped", + method: http.MethodPatch, + rawURL: "https://shop.example.com/api/scheduled-task/550e8400-e29b-41d4-a716-446655440000", + want: "PATCH {host}/api/scheduled-task/{id}", + }, + { + name: "numeric path segment grouped", + method: http.MethodGet, + rawURL: "https://sitespeed.internal/api/result/42", + want: "GET {host}/api/result/{id}", + }, + { + name: "long hex path segment grouped", + method: http.MethodGet, + rawURL: "https://shop.example.com/api/token/0123456789abcdef0123456789abcdef", + want: "GET {host}/api/token/{id}", + }, + { + name: "shared github host kept literal", + method: http.MethodGet, + rawURL: "https://raw.githubusercontent.com/FriendsOfShopware/shopware-static-data/main/data/security.json", + want: "GET raw.githubusercontent.com/FriendsOfShopware/shopware-static-data/main/data/security.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + u, err := url.Parse(tt.rawURL) + if err != nil { + t.Fatalf("parse URL: %v", err) + } + req := &http.Request{ + Method: tt.method, + URL: u, + Host: tt.host, + } + + got := ClientSpanName("", req) + if got != tt.want { + t.Fatalf("ClientSpanName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClientSpanName_NilRequest(t *testing.T) { + t.Parallel() + if got := ClientSpanName("", nil); got != http.MethodGet { + t.Fatalf("ClientSpanName(nil) = %q, want %q", got, http.MethodGet) + } +} + +func TestSpanPath(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"", "/"}, + {"/", "/"}, + {"/pluginStore/pluginsByName", "/pluginStore/pluginsByName"}, + {"/api/result/99", "/api/result/{id}"}, + {"/api/scheduled-task/550e8400-e29b-41d4-a716-446655440000/run", "/api/scheduled-task/{id}/run"}, + {"/_info/config", "/_info/config"}, + } + for _, tt := range tests { + if got := spanPath(tt.in); got != tt.want { + t.Fatalf("spanPath(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/api/internal/shopware/client.go b/api/internal/shopware/client.go index 361c9168..a2c5db15 100644 --- a/api/internal/shopware/client.go +++ b/api/internal/shopware/client.go @@ -12,10 +12,6 @@ import ( "time" "github.com/friendsofshopware/shopmon/api/internal/httputil" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" "golang.org/x/sync/singleflight" ) @@ -169,21 +165,12 @@ func (c *Client) Authenticate(ctx context.Context) error { return err } -var tracer = otel.Tracer("shopmon/shopware") - +// request performs an authenticated Admin API call. HTTP client tracing comes +// from httputil's otelhttp transport (span name METHOD host/path); we do not +// create a second Internal span that would duplicate that client span in Datadog. func (c *Client) request(ctx context.Context, method, path string, body interface{}, retry bool) ([]byte, error) { - ctx, span := tracer.Start(ctx, method+" "+path, - trace.WithAttributes( - attribute.String("http.method", method), - attribute.String("http.url", c.baseURL+"/api"+path), - ), - ) - defer span.End() - token, err := c.getToken(ctx) if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) return nil, err } @@ -211,14 +198,10 @@ func (c *Client) request(ctx context.Context, method, path string, body interfac resp, err := c.httpClient.Do(req) if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) return nil, fmt.Errorf("request: %w", err) } defer func() { _ = resp.Body.Close() }() - span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode)) - if resp.StatusCode == 301 || resp.StatusCode == 302 { return nil, &ApiError{StatusCode: resp.StatusCode, Body: "redirect detected"} } @@ -234,10 +217,7 @@ func (c *Client) request(ctx context.Context, method, path string, body interfac } if resp.StatusCode >= 400 { - apiErr := &ApiError{StatusCode: resp.StatusCode, Body: string(respBody)} - span.RecordError(apiErr) - span.SetStatus(codes.Error, apiErr.Error()) - return nil, apiErr + return nil, &ApiError{StatusCode: resp.StatusCode, Body: string(respBody)} } return respBody, nil