diff --git a/api/standard/http.go b/api/standard/http.go new file mode 100644 index 00000000000..11ee0f3d31c --- /dev/null +++ b/api/standard/http.go @@ -0,0 +1,303 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standard + +import ( + "fmt" + "net" + "net/http" + "strconv" + "strings" + + "google.golang.org/grpc/codes" + + "go.opentelemetry.io/otel/api/kv" +) + +// NetAttributesFromHTTPRequest generates attributes of the net +// namespace as specified by the OpenTelemetry specification for a +// span. The network parameter is a string that net.Dial function +// from standard library can understand. +func NetAttributesFromHTTPRequest(network string, request *http.Request) []kv.KeyValue { + attrs := []kv.KeyValue{} + + switch network { + case "tcp", "tcp4", "tcp6": + attrs = append(attrs, NetTransportTCP) + case "udp", "udp4", "udp6": + attrs = append(attrs, NetTransportUDP) + case "ip", "ip4", "ip6": + attrs = append(attrs, NetTransportIP) + case "unix", "unixgram", "unixpacket": + attrs = append(attrs, NetTransportUnix) + default: + attrs = append(attrs, NetTransportOther) + } + + peerName, peerIP, peerPort := "", "", 0 + { + hostPart := request.RemoteAddr + portPart := "" + if idx := strings.LastIndex(hostPart, ":"); idx >= 0 { + hostPart = request.RemoteAddr[:idx] + portPart = request.RemoteAddr[idx+1:] + } + if hostPart != "" { + if ip := net.ParseIP(hostPart); ip != nil { + peerIP = ip.String() + } else { + peerName = hostPart + } + + if portPart != "" { + numPort, err := strconv.ParseUint(portPart, 10, 16) + if err == nil { + peerPort = (int)(numPort) + } else { + peerName, peerIP = "", "" + } + } + } + } + if peerName != "" { + attrs = append(attrs, NetPeerNameKey.String(peerName)) + } + if peerIP != "" { + attrs = append(attrs, NetPeerIPKey.String(peerIP)) + } + if peerPort != 0 { + attrs = append(attrs, NetPeerPortKey.Int(peerPort)) + } + + hostIP, hostName, hostPort := "", "", 0 + for _, someHost := range []string{request.Host, request.Header.Get("Host"), request.URL.Host} { + hostPart := "" + if idx := strings.LastIndex(someHost, ":"); idx >= 0 { + strPort := someHost[idx+1:] + numPort, err := strconv.ParseUint(strPort, 10, 16) + if err == nil { + hostPort = (int)(numPort) + } + hostPart = someHost[:idx] + } else { + hostPart = someHost + } + if hostPart != "" { + ip := net.ParseIP(hostPart) + if ip != nil { + hostIP = ip.String() + } else { + hostName = hostPart + } + break + } else { + hostPort = 0 + } + } + if hostIP != "" { + attrs = append(attrs, NetHostIPKey.String(hostIP)) + } + if hostName != "" { + attrs = append(attrs, NetHostNameKey.String(hostName)) + } + if hostPort != 0 { + attrs = append(attrs, NetHostPortKey.Int(hostPort)) + } + + return attrs +} + +// EndUserAttributesFromHTTPRequest generates attributes of the +// enduser namespace as specified by the OpenTelemetry specification +// for a span. +func EndUserAttributesFromHTTPRequest(request *http.Request) []kv.KeyValue { + if username, _, ok := request.BasicAuth(); ok { + return []kv.KeyValue{EnduserIDKey.String(username)} + } + return nil +} + +// HTTPClientAttributesFromHTTPRequest generates attributes of the +// http namespace as specified by the OpenTelemetry specification for +// a span on the client side. +func HTTPClientAttributesFromHTTPRequest(request *http.Request) []kv.KeyValue { + attrs := []kv.KeyValue{} + + if request.Method != "" { + attrs = append(attrs, HTTPMethodKey.String(request.Method)) + } else { + attrs = append(attrs, HTTPMethodKey.String(http.MethodGet)) + } + + attrs = append(attrs, HTTPUrlKey.String(request.URL.String())) + + return append(attrs, httpCommonAttributesFromHTTPRequest(request)...) +} + +func httpCommonAttributesFromHTTPRequest(request *http.Request) []kv.KeyValue { + attrs := []kv.KeyValue{} + + if request.TLS != nil { + attrs = append(attrs, HTTPSchemeHTTPS) + } else { + attrs = append(attrs, HTTPSchemeHTTP) + } + + if request.Host != "" { + attrs = append(attrs, HTTPHostKey.String(request.Host)) + } + + if ua := request.UserAgent(); ua != "" { + attrs = append(attrs, HTTPUserAgentKey.String(ua)) + } + + flavor := "" + if request.ProtoMajor == 1 { + flavor = fmt.Sprintf("1.%d", request.ProtoMinor) + } else if request.ProtoMajor == 2 { + flavor = "2" + } + if flavor != "" { + attrs = append(attrs, HTTPFlavorKey.String(flavor)) + } + + return attrs +} + +// HTTPServerAttributesFromHTTPRequest generates attributes of the +// http namespace as specified by the OpenTelemetry specification for +// a span on the server side. Currently, only basic authentication is +// supported. +func HTTPServerAttributesFromHTTPRequest(serverName, route string, request *http.Request) []kv.KeyValue { + attrs := []kv.KeyValue{ + HTTPMethodKey.String(request.Method), + HTTPTargetKey.String(request.RequestURI), + } + + if serverName != "" { + attrs = append(attrs, HTTPServerNameKey.String(serverName)) + } + if route != "" { + attrs = append(attrs, HTTPRouteKey.String(route)) + } + if values, ok := request.Header["X-Forwarded-For"]; ok && len(values) > 0 { + attrs = append(attrs, HTTPClientIPKey.String(values[0])) + } + + return append(attrs, httpCommonAttributesFromHTTPRequest(request)...) +} + +// HTTPAttributesFromHTTPStatusCode generates attributes of the http +// namespace as specified by the OpenTelemetry specification for a +// span. +func HTTPAttributesFromHTTPStatusCode(code int) []kv.KeyValue { + attrs := []kv.KeyValue{ + HTTPStatusCodeKey.Int(code), + } + text := http.StatusText(code) + if text != "" { + attrs = append(attrs, HTTPStatusTextKey.String(text)) + } + return attrs +} + +type codeRange struct { + fromInclusive int + toInclusive int +} + +func (r codeRange) contains(code int) bool { + return r.fromInclusive <= code && code <= r.toInclusive +} + +var validRangesPerCategory = map[int][]codeRange{ + 1: { + {http.StatusContinue, http.StatusEarlyHints}, + }, + 2: { + {http.StatusOK, http.StatusAlreadyReported}, + {http.StatusIMUsed, http.StatusIMUsed}, + }, + 3: { + {http.StatusMultipleChoices, http.StatusUseProxy}, + {http.StatusTemporaryRedirect, http.StatusPermanentRedirect}, + }, + 4: { + {http.StatusBadRequest, http.StatusTeapot}, // yes, teapot is so useful… + {http.StatusMisdirectedRequest, http.StatusUpgradeRequired}, + {http.StatusPreconditionRequired, http.StatusTooManyRequests}, + {http.StatusRequestHeaderFieldsTooLarge, http.StatusRequestHeaderFieldsTooLarge}, + {http.StatusUnavailableForLegalReasons, http.StatusUnavailableForLegalReasons}, + }, + 5: { + {http.StatusInternalServerError, http.StatusLoopDetected}, + {http.StatusNotExtended, http.StatusNetworkAuthenticationRequired}, + }, +} + +// SpanStatusFromHTTPStatusCode generates a status code and a message +// as specified by the OpenTelemetry specification for a span. +func SpanStatusFromHTTPStatusCode(code int) (codes.Code, string) { + spanCode := func() codes.Code { + category := code / 100 + ranges, ok := validRangesPerCategory[category] + if !ok { + return codes.Unknown + } + ok = false + for _, crange := range ranges { + ok = crange.contains(code) + if ok { + break + } + } + if !ok { + return codes.Unknown + } + switch code { + case http.StatusUnauthorized: + return codes.Unauthenticated + case http.StatusForbidden: + return codes.PermissionDenied + case http.StatusNotFound: + return codes.NotFound + case http.StatusTooManyRequests: + return codes.ResourceExhausted + case http.StatusNotImplemented: + return codes.Unimplemented + case http.StatusServiceUnavailable: + return codes.Unavailable + case http.StatusGatewayTimeout: + return codes.DeadlineExceeded + } + if category > 0 && category < 4 { + return codes.OK + } + if category == 4 { + return codes.InvalidArgument + } + if category == 5 { + return codes.Internal + } + // this really should not happen, if we get there then + // it means that the code got out of sync with + // validRangesPerCategory map + return codes.Unknown + }() + if spanCode == codes.Unknown { + return spanCode, fmt.Sprintf("Invalid HTTP status code %d", code) + } + return spanCode, fmt.Sprintf("HTTP status code: %d", code) +} diff --git a/api/standard/http_test.go b/api/standard/http_test.go new file mode 100644 index 00000000000..21fcd9efc6e --- /dev/null +++ b/api/standard/http_test.go @@ -0,0 +1,777 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package standard + +import ( + "crypto/tls" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + + otelkv "go.opentelemetry.io/otel/api/kv" +) + +type tlsOption int + +const ( + noTLS tlsOption = iota + withTLS +) + +func TestNetAttributesFromHTTPRequest(t *testing.T) { + type testcase struct { + name string + + network string + + method string + requestURI string + proto string + remoteAddr string + host string + url *url.URL + header http.Header + + expected []otelkv.KeyValue + } + testcases := []testcase{ + { + name: "stripped, tcp", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + }, + }, + { + name: "stripped, udp", + network: "udp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.UDP"), + }, + }, + { + name: "stripped, ip", + network: "ip", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP"), + }, + }, + { + name: "stripped, unix", + network: "unix", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "Unix"), + }, + }, + { + name: "stripped, other", + network: "nih", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "other"), + }, + }, + { + name: "with remote ip and port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + }, + }, + { + name: "with remote name and port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "example.com:56", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.name", "example.com"), + otelkv.Int("net.peer.port", 56), + }, + }, + { + name: "with remote ip only", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + }, + }, + { + name: "with remote name only", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "example.com", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.name", "example.com"), + }, + }, + { + name: "with remote port only", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: ":56", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + }, + }, + { + name: "with host name only", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.name", "example.com"), + }, + }, + { + name: "with host ip only", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "4.3.2.1", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.ip", "4.3.2.1"), + }, + }, + { + name: "with host name and port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "example.com:78", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.name", "example.com"), + otelkv.Int("net.host.port", 78), + }, + }, + { + name: "with host ip and port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "4.3.2.1:78", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.ip", "4.3.2.1"), + otelkv.Int("net.host.port", 78), + }, + }, + { + name: "with host name and bogus port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "example.com:qwerty", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.name", "example.com"), + }, + }, + { + name: "with host ip and bogus port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "4.3.2.1:qwerty", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.ip", "4.3.2.1"), + }, + }, + { + name: "with empty host and port", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: ":80", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + }, + }, + { + name: "with host ip and port in headers", + network: "tcp", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: http.Header{ + "Host": []string{"4.3.2.1:78"}, + }, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.ip", "4.3.2.1"), + otelkv.Int("net.host.port", 78), + }, + }, + { + name: "with host ip and port in url", + network: "tcp", + method: "GET", + requestURI: "http://4.3.2.1:78/user/123", + proto: "HTTP/1.0", + remoteAddr: "1.2.3.4:56", + host: "", + url: &url.URL{ + Host: "4.3.2.1:78", + Path: "/user/123", + }, + header: nil, + expected: []otelkv.KeyValue{ + otelkv.String("net.transport", "IP.TCP"), + otelkv.String("net.peer.ip", "1.2.3.4"), + otelkv.Int("net.peer.port", 56), + otelkv.String("net.host.ip", "4.3.2.1"), + otelkv.Int("net.host.port", 78), + }, + }, + } + for idx, tc := range testcases { + r := testRequest(tc.method, tc.requestURI, tc.proto, tc.remoteAddr, tc.host, tc.url, tc.header, noTLS) + got := NetAttributesFromHTTPRequest(tc.network, r) + assertElementsMatch(t, tc.expected, got, "testcase %d - %s", idx, tc.name) + } +} + +func TestEndUserAttributesFromHTTPRequest(t *testing.T) { + r := testRequest("GET", "/user/123", "HTTP/1.1", "", "", nil, http.Header{}, withTLS) + var expected []otelkv.KeyValue + got := EndUserAttributesFromHTTPRequest(r) + assert.ElementsMatch(t, expected, got) + r.SetBasicAuth("admin", "password") + expected = []otelkv.KeyValue{otelkv.String("enduser.id", "admin")} + got = EndUserAttributesFromHTTPRequest(r) + assert.ElementsMatch(t, expected, got) +} + +func TestHTTPServerAttributesFromHTTPRequest(t *testing.T) { + type testcase struct { + name string + + serverName string + route string + + method string + requestURI string + proto string + remoteAddr string + host string + url *url.URL + header http.Header + tls tlsOption + + expected []otelkv.KeyValue + } + testcases := []testcase{ + { + name: "stripped", + serverName: "", + route: "", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + tls: noTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "http"), + otelkv.String("http.flavor", "1.0"), + }, + }, + { + name: "with server name", + serverName: "my-server-name", + route: "", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + tls: noTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "http"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + }, + }, + { + name: "with tls", + serverName: "my-server-name", + route: "", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + }, + }, + { + name: "with route", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + }, + }, + { + name: "with host", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: nil, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + otelkv.String("http.host", "example.com"), + }, + }, + { + name: "with user agent", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: http.Header{ + "User-Agent": []string{"foodownloader"}, + }, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + otelkv.String("http.host", "example.com"), + otelkv.String("http.user_agent", "foodownloader"), + }, + }, + { + name: "with proxy info", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.0", + remoteAddr: "", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: http.Header{ + "User-Agent": []string{"foodownloader"}, + "X-Forwarded-For": []string{"1.2.3.4"}, + }, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.0"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + otelkv.String("http.host", "example.com"), + otelkv.String("http.user_agent", "foodownloader"), + otelkv.String("http.client_ip", "1.2.3.4"), + }, + }, + { + name: "with http 1.1", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/1.1", + remoteAddr: "", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: http.Header{ + "User-Agent": []string{"foodownloader"}, + "X-Forwarded-For": []string{"1.2.3.4"}, + }, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "1.1"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + otelkv.String("http.host", "example.com"), + otelkv.String("http.user_agent", "foodownloader"), + otelkv.String("http.client_ip", "1.2.3.4"), + }, + }, + { + name: "with http 2", + serverName: "my-server-name", + route: "/user/:id", + method: "GET", + requestURI: "/user/123", + proto: "HTTP/2.0", + remoteAddr: "", + host: "example.com", + url: &url.URL{ + Path: "/user/123", + }, + header: http.Header{ + "User-Agent": []string{"foodownloader"}, + "X-Forwarded-For": []string{"1.2.3.4"}, + }, + tls: withTLS, + expected: []otelkv.KeyValue{ + otelkv.String("http.method", "GET"), + otelkv.String("http.target", "/user/123"), + otelkv.String("http.scheme", "https"), + otelkv.String("http.flavor", "2"), + otelkv.String("http.server_name", "my-server-name"), + otelkv.String("http.route", "/user/:id"), + otelkv.String("http.host", "example.com"), + otelkv.String("http.user_agent", "foodownloader"), + otelkv.String("http.client_ip", "1.2.3.4"), + }, + }, + } + for idx, tc := range testcases { + r := testRequest(tc.method, tc.requestURI, tc.proto, tc.remoteAddr, tc.host, tc.url, tc.header, tc.tls) + got := HTTPServerAttributesFromHTTPRequest(tc.serverName, tc.route, r) + assertElementsMatch(t, tc.expected, got, "testcase %d - %s", idx, tc.name) + } +} + +func TestHTTPAttributesFromHTTPStatusCode(t *testing.T) { + expected := []otelkv.KeyValue{ + otelkv.Int("http.status_code", 404), + otelkv.String("http.status_text", "Not Found"), + } + got := HTTPAttributesFromHTTPStatusCode(http.StatusNotFound) + assertElementsMatch(t, expected, got, "with valid HTTP status code") + assert.ElementsMatch(t, expected, got) + expected = []otelkv.KeyValue{ + otelkv.Int("http.status_code", 499), + } + got = HTTPAttributesFromHTTPStatusCode(499) + assertElementsMatch(t, expected, got, "with invalid HTTP status code") +} + +func TestSpanStatusFromHTTPStatusCode(t *testing.T) { + for code := 0; code < 1000; code++ { + expected := getExpectedGRPCCodeForHTTPCode(code) + got, _ := SpanStatusFromHTTPStatusCode(code) + assert.Equalf(t, expected, got, "%s vs %s", expected, got) + } +} + +func getExpectedGRPCCodeForHTTPCode(code int) codes.Code { + if http.StatusText(code) == "" { + return codes.Unknown + } + switch code { + case http.StatusUnauthorized: + return codes.Unauthenticated + case http.StatusForbidden: + return codes.PermissionDenied + case http.StatusNotFound: + return codes.NotFound + case http.StatusTooManyRequests: + return codes.ResourceExhausted + case http.StatusNotImplemented: + return codes.Unimplemented + case http.StatusServiceUnavailable: + return codes.Unavailable + case http.StatusGatewayTimeout: + return codes.DeadlineExceeded + } + category := code / 100 + if category < 4 { + return codes.OK + } + if category < 5 { + return codes.InvalidArgument + } + return codes.Internal +} + +func assertElementsMatch(t *testing.T, expected, got []otelkv.KeyValue, format string, args ...interface{}) { + if !assert.ElementsMatchf(t, expected, got, format, args...) { + t.Log("expected:", kvStr(expected)) + t.Log("got:", kvStr(got)) + } +} + +func testRequest(method, requestURI, proto, remoteAddr, host string, u *url.URL, header http.Header, tlsopt tlsOption) *http.Request { + major, minor := protoToInts(proto) + var tlsConn *tls.ConnectionState + switch tlsopt { + case noTLS: + case withTLS: + tlsConn = &tls.ConnectionState{} + } + return &http.Request{ + Method: method, + URL: u, + Proto: proto, + ProtoMajor: major, + ProtoMinor: minor, + Header: header, + Host: host, + RemoteAddr: remoteAddr, + RequestURI: requestURI, + TLS: tlsConn, + } +} + +func protoToInts(proto string) (int, int) { + switch proto { + case "HTTP/1.0": + return 1, 0 + case "HTTP/1.1": + return 1, 1 + case "HTTP/2.0": + return 2, 0 + } + // invalid proto + return 13, 42 +} + +func kvStr(kvs []otelkv.KeyValue) string { + sb := strings.Builder{} + sb.WriteRune('[') + for idx, kv := range kvs { + if idx > 0 { + sb.WriteString(", ") + } + sb.WriteString((string)(kv.Key)) + sb.WriteString(": ") + sb.WriteString(kv.Value.Emit()) + } + sb.WriteRune(']') + return sb.String() +} diff --git a/plugin/httptrace/httptrace.go b/plugin/httptrace/httptrace.go index 87746574d5e..af7da000aff 100644 --- a/plugin/httptrace/httptrace.go +++ b/plugin/httptrace/httptrace.go @@ -22,6 +22,7 @@ import ( "go.opentelemetry.io/otel/api/global" "go.opentelemetry.io/otel/api/kv" "go.opentelemetry.io/otel/api/propagation" + "go.opentelemetry.io/otel/api/standard" "go.opentelemetry.io/otel/api/trace" ) @@ -34,10 +35,10 @@ var ( func Extract(ctx context.Context, req *http.Request) ([]kv.KeyValue, []kv.KeyValue, trace.SpanContext) { ctx = propagation.ExtractHTTP(ctx, global.Propagators(), req.Header) - attrs := []kv.KeyValue{ - URLKey.String(req.URL.String()), - // Etc. - } + attrs := append( + standard.HTTPServerAttributesFromHTTPRequest("", "", req), + standard.NetAttributesFromHTTPRequest("tcp", req)..., + ) var correlationCtxKVs []kv.KeyValue correlation.MapFromContext(ctx).Foreach(func(kv kv.KeyValue) bool { diff --git a/plugin/othttp/common.go b/plugin/othttp/common.go index ff48ac8548a..365ccadf33e 100644 --- a/plugin/othttp/common.go +++ b/plugin/othttp/common.go @@ -18,20 +18,10 @@ import ( "net/http" "go.opentelemetry.io/otel/api/kv" - - "go.opentelemetry.io/otel/api/trace" ) // Attribute keys that can be added to a span. const ( - HostKey = kv.Key("http.host") // the HTTP host (http.Request.Host) - MethodKey = kv.Key("http.method") // the HTTP method (http.Request.Method) - PathKey = kv.Key("http.path") // the HTTP path (http.Request.URL.Path) - URLKey = kv.Key("http.url") // the HTTP URL (http.Request.URL.String()) - UserAgentKey = kv.Key("http.user_agent") // the HTTP user agent (http.Request.UserAgent()) - RouteKey = kv.Key("http.route") // the HTTP route (ex: /users/:id) - RemoteAddrKey = kv.Key("http.remote_addr") // the network address of the client that sent the HTTP request (http.Request.RemoteAddr) - StatusCodeKey = kv.Key("http.status_code") // if set, the HTTP status ReadBytesKey = kv.Key("http.read_bytes") // if anything was read from the request body, the total number of bytes read ReadErrorKey = kv.Key("http.read_error") // If an error occurred while reading a request, the string of the error (io.EOF is not recorded) WroteBytesKey = kv.Key("http.wrote_bytes") // if anything was written to the response writer, the total number of bytes written @@ -41,15 +31,3 @@ const ( // Filter is a predicate used to determine whether a given http.request should // be traced. A Filter must return true if the request should be traced. type Filter func(*http.Request) bool - -// Setup basic span attributes before so that they -// are available to be mutated if needed. -func setBasicAttributes(span trace.Span, r *http.Request) { - span.SetAttributes( - HostKey.String(r.Host), - MethodKey.String(r.Method), - PathKey.String(r.URL.Path), - URLKey.String(r.URL.String()), - UserAgentKey.String(r.UserAgent()), - ) -} diff --git a/plugin/othttp/handler.go b/plugin/othttp/handler.go index c75c8d1fb77..3e63b3933ae 100644 --- a/plugin/othttp/handler.go +++ b/plugin/othttp/handler.go @@ -21,6 +21,7 @@ import ( "go.opentelemetry.io/otel/api/global" "go.opentelemetry.io/otel/api/kv" "go.opentelemetry.io/otel/api/propagation" + "go.opentelemetry.io/otel/api/standard" "go.opentelemetry.io/otel/api/trace" ) @@ -88,7 +89,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - opts := append([]trace.StartOption{}, h.spanStartOptions...) // start with the configured options + opts := append([]trace.StartOption{ + trace.WithAttributes(standard.NetAttributesFromHTTPRequest("tcp", r)...), + trace.WithAttributes(standard.EndUserAttributesFromHTTPRequest(r)...), + trace.WithAttributes(standard.HTTPServerAttributesFromHTTPRequest(h.operation, "", r)...), + }, h.spanStartOptions...) // start with the configured options ctx := propagation.ExtractHTTP(r.Context(), h.propagators, r.Header) ctx, span := h.tracer.Start(ctx, h.spanNameFormatter(h.operation, r), opts...) @@ -112,16 +117,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rww := &respWriterWrapper{ResponseWriter: w, record: writeRecordFunc, ctx: ctx, props: h.propagators} - setBasicAttributes(span, r) - span.SetAttributes(RemoteAddrKey.String(r.RemoteAddr)) - h.handler.ServeHTTP(rww, r.WithContext(ctx)) - setAfterServeAttributes(span, bw.read, rww.written, int64(rww.statusCode), bw.err, rww.err) + setAfterServeAttributes(span, bw.read, rww.written, rww.statusCode, bw.err, rww.err) } -func setAfterServeAttributes(span trace.Span, read, wrote, statusCode int64, rerr, werr error) { - kv := make([]kv.KeyValue, 0, 5) +func setAfterServeAttributes(span trace.Span, read, wrote int64, statusCode int, rerr, werr error) { + kv := []kv.KeyValue{} + // TODO: Consider adding an event after each read and write, possibly as an // option (defaulting to off), so as to not create needlessly verbose spans. if read > 0 { @@ -134,7 +137,7 @@ func setAfterServeAttributes(span trace.Span, read, wrote, statusCode int64, rer kv = append(kv, WroteBytesKey.Int64(wrote)) } if statusCode > 0 { - kv = append(kv, StatusCodeKey.Int64(statusCode)) + kv = append(kv, standard.HTTPAttributesFromHTTPStatusCode(statusCode)...) } if werr != nil && werr != io.EOF { kv = append(kv, WriteErrorKey.String(werr.Error())) @@ -147,7 +150,7 @@ func setAfterServeAttributes(span trace.Span, read, wrote, statusCode int64, rer func WithRouteTag(route string, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { span := trace.SpanFromContext(r.Context()) - span.SetAttributes(RouteKey.String(route)) + span.SetAttributes(standard.HTTPRouteKey.String(route)) h.ServeHTTP(w, r) }) } diff --git a/plugin/othttp/transport.go b/plugin/othttp/transport.go index 52fc2d5a7af..19fb93ddcfa 100644 --- a/plugin/othttp/transport.go +++ b/plugin/othttp/transport.go @@ -21,6 +21,7 @@ import ( "go.opentelemetry.io/otel/api/global" "go.opentelemetry.io/otel/api/propagation" + "go.opentelemetry.io/otel/api/standard" "go.opentelemetry.io/otel/api/trace" "google.golang.org/grpc/codes" @@ -88,7 +89,7 @@ func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) { ctx, span := t.tracer.Start(r.Context(), t.spanNameFormatter("", r), opts...) r = r.WithContext(ctx) - setBasicAttributes(span, r) + span.SetAttributes(standard.HTTPClientAttributesFromHTTPRequest(r)...) propagation.InjectHTTP(ctx, t.propagators, r.Header) res, err := t.rt.RoundTrip(r) @@ -98,7 +99,8 @@ func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) { return res, err } - span.SetAttributes(StatusCodeKey.Int(res.StatusCode)) + span.SetAttributes(standard.HTTPAttributesFromHTTPStatusCode(res.StatusCode)...) + span.SetStatus(standard.SpanStatusFromHTTPStatusCode(res.StatusCode)) res.Body = &wrappedBody{ctx: ctx, span: span, body: res.Body} return res, err