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