Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions api/internal/httputil/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
190 changes: 190 additions & 0 deletions api/internal/httputil/client_span_name.go
Original file line number Diff line number Diff line change
@@ -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')
}
136 changes: 136 additions & 0 deletions api/internal/httputil/client_span_name_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading