Files
go-otel/internal/httpattr/httpattr.go
T
2026-09-19 14:13:43 +02:00

137 lines
3.7 KiB
Go

// 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
}