mreo ui
This commit is contained in:
1 parent
49f9bb3458
commit
7436966689
22 files changed
+989
-125
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/httpattr"
|
||||
"git.tornberg.me/go-otel/internal/issues"
|
||||
"go.opentelemetry.io/collector/pdata/pcommon"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
@@ -183,12 +184,11 @@ func firstAttr(attrs pcommon.Map, keys ...string) string {
|
||||
// as a generic router mount. This keeps API endpoints distinct in issue
|
||||
// fingerprints and avoids collapsing unrelated requests into one root route.
|
||||
func httpRoute(attrs pcommon.Map) string {
|
||||
route := firstAttr(attrs, "http.route")
|
||||
path := firstAttr(attrs, "url.path", "http.target")
|
||||
if path != "" && (route == "" || route == "/" && path != "/") {
|
||||
return path
|
||||
ep := httpattr.Extract(func(keys ...string) string { return firstAttr(attrs, keys...) })
|
||||
if ep.Route != "" {
|
||||
return ep.Route
|
||||
}
|
||||
return route
|
||||
return ep.Path
|
||||
}
|
||||
|
||||
// getHTTPStatusCode extracts an HTTP status code from attributes, supporting
|
||||
@@ -332,7 +332,7 @@ func spanErrorTitle(span ptrace.Span) string {
|
||||
}
|
||||
|
||||
method := firstAttr(span.Attributes(), "http.request.method", "http.method")
|
||||
route := firstAttr(span.Attributes(), "http.route", "http.target", "url.path")
|
||||
route := httpRoute(span.Attributes())
|
||||
|
||||
var endpoint string
|
||||
if method != "" && route != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/httpattr"
|
||||
"git.tornberg.me/go-otel/internal/issues"
|
||||
)
|
||||
|
||||
@@ -397,14 +398,13 @@ func defaultString(value, fallback string) string {
|
||||
func normalizeMessage(message string) string { return issues.NormalizeTitle(message) }
|
||||
|
||||
func normalizeHTTPRoute(attrs []attribute) string {
|
||||
route := stringAttr(attrs, "http.route")
|
||||
path := stringAttr(attrs, "url.path", "http.target")
|
||||
// Some HTTP instrumentations report the router mount point (often "/")
|
||||
// as http.route while url.path contains the actual endpoint. Prefer the
|
||||
// specific path in that case so unrelated API calls do not collapse into
|
||||
// one pattern.
|
||||
if path != "" && (route == "" || route == "/" && path != "/") {
|
||||
route = path
|
||||
ep := httpattr.Extract(func(keys ...string) string { return stringAttr(attrs, keys...) })
|
||||
// Prefer the template when the instrumentation gave a meaningful one,
|
||||
// otherwise the concrete path. A mount-point route ("/") is dropped by
|
||||
// Extract so unrelated API calls do not collapse into one pattern.
|
||||
route := ep.Route
|
||||
if route == "" {
|
||||
route = ep.Path
|
||||
}
|
||||
return issues.NormalizeRoute(route)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user