This commit is contained in:
mats committed 2026-09-19 14:13:43 +02:00
1 parent 49f9bb3458
commit 7436966689
22 files changed
+989 -125

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
// Package httpattr collapses the many attribute spellings used for "which
// HTTP endpoint was this" (OTel stable semconv, legacy semconv, nginx, Go
// net/http, browser RUM) into one Endpoint, so spans from different
// instrumentations can be compared and joined.
package httpattr
import (
"net/url"
"strings"
)
// Endpoint is the normalized identity of one HTTP request.
type Endpoint struct {
Method string // upper-case, e.g. "GET"
Host string // host without port, lower-case; "" if unknown
Path string // concrete path, no query/fragment, no trailing slash
Route string // instrumentation-provided template ("/orders/:id"), may be ""
}
// Key is the join key between a client-side (RUM) request and a server-side
// span: method plus the concrete path. Route is deliberately not part of it -
// a browser only knows the concrete URL.
func (e Endpoint) Key() string {
if e.Path == "" {
return ""
}
return e.Method + " " + e.Path
}
// Lookup returns the first non-empty value among keys.
type Lookup func(keys ...string) string
// Extract reads an Endpoint out of span/log attributes.
//
// Sources, in order of preference:
//
// method: http.request.method, http.method
// path: url.path, http.target (path+query), url.full, http.url
// host: server.address, http.host, net.host.name, url.full/http.url host
// route: http.route
//
// nginx's OTel module emits the stable keys; older nginx/Go/Java agents emit
// http.target/http.url; browsers only know url.full (absolute or relative).
func Extract(get Lookup) Endpoint {
e := Endpoint{
Method: strings.ToUpper(get("http.request.method", "http.method")),
Route: get("http.route"),
}
e.Host = stripPort(strings.ToLower(get("server.address", "http.host", "net.host.name", "server.host")))
if p := get("url.path"); p != "" {
e.Path = CleanPath(p)
} else if t := get("http.target"); t != "" {
e.Path = CleanPath(t)
}
if full := get("url.full", "http.url"); full != "" {
host, path := SplitURL(full)
if e.Path == "" {
e.Path = path
}
if e.Host == "" {
e.Host = host
}
}
// Some instrumentations report the router mount ("/") as the route.
if e.Route == "/" && e.Path != "" && e.Path != "/" {
e.Route = ""
}
return e
}
// SplitURL splits an absolute or relative URL into (host without port, clean path).
func SplitURL(raw string) (host, path string) {
u, err := url.Parse(raw)
if err != nil {
return "", CleanPath(raw)
}
return stripPort(strings.ToLower(u.Host)), CleanPath(u.Path)
}
// CleanPath drops query/fragment and a trailing slash, and guarantees a
// leading slash. "" stays "".
func CleanPath(p string) string {
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if len(p) > 1 {
p = strings.TrimRight(p, "/")
if p == "" {
p = "/"
}
}
return p
}
// StripPrefix removes a reverse-proxy mount prefix (e.g. nginx
// `location /api/ { proxy_pass http://backend/; }` makes the browser see
// /api/orders while the backend sees /orders). The longest matching prefix wins.
func StripPrefix(path string, prefixes []string) string {
best := ""
for _, p := range prefixes {
p = CleanPath(p)
if p == "" || p == "/" {
continue
}
if (path == p || strings.HasPrefix(path, p+"/")) && len(p) > len(best) {
best = p
}
}
if best == "" {
return path
}
if rest := path[len(best):]; rest != "" {
return rest
}
return "/"
}
func stripPort(host string) string {
if strings.HasPrefix(host, "[") { // IPv6 literal
if i := strings.Index(host, "]"); i > 0 {
return host[:i+1]
}
return host
}
if i := strings.LastIndexByte(host, ':'); i >= 0 {
return host[:i]
}
return host
}
+49
View File
@@ -0,0 +1,49 @@
package httpattr
import "testing"
func lookup(m map[string]string) Lookup {
return func(keys ...string) string {
for _, k := range keys {
if v := m[k]; v != "" {
return v
}
}
return ""
}
}
func TestExtractVariants(t *testing.T) {
cases := []struct {
name string
attrs map[string]string
want string
}{
{"stable semconv", map[string]string{"http.request.method": "get", "url.path": "/api/orders/"}, "GET /api/orders"},
{"legacy nginx target", map[string]string{"http.method": "GET", "http.target": "/api/orders?x=1"}, "GET /api/orders"},
{"go http.url", map[string]string{"http.method": "POST", "http.url": "https://shop.example.com:8443/api/cart?a=b"}, "POST /api/cart"},
{"browser url.full", map[string]string{"http.request.method": "GET", "url.full": "https://shop.example.com/api/orders#x"}, "GET /api/orders"},
{"relative url.full", map[string]string{"http.request.method": "GET", "url.full": "api/orders"}, "GET /api/orders"},
}
for _, c := range cases {
if got := Extract(lookup(c.attrs)).Key(); got != c.want {
t.Errorf("%s: got %q want %q", c.name, got, c.want)
}
}
}
func TestExtractHostAndMountRoute(t *testing.T) {
e := Extract(lookup(map[string]string{"http.route": "/", "url.path": "/x", "server.address": "API.example.com:443"}))
if e.Host != "api.example.com" || e.Route != "" {
t.Fatalf("got %+v", e)
}
}
func TestStripPrefix(t *testing.T) {
p := []string{"/api", "/api/v2"}
for in, want := range map[string]string{"/api/orders": "/orders", "/api/v2/orders": "/orders", "/api": "/", "/apix": "/apix", "/other": "/other"} {
if got := StripPrefix(in, p); got != want {
t.Errorf("%s: got %q want %q", in, got, want)
}
}
}