diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f285c97db..eaf466f00bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - The `WithSchemaURL` option function in `go.opentelemetry.io/contrib/bridges/otelslog`. This option function is used as a replacement of `WithInstrumentationScope` to specify the semantic convention schema URL for the logged records. (#5588) - Add support for Cloud Run jobs in `go.opentelemetry.io/contrib/detectors/gcp`. (#5559) +- Add TLS information from semantic conventions v1.24.0 to `go.opentelemetry.io/contrib/instrumentation/net/http/httptrace`. (#5563) ### Changed diff --git a/instrumentation/net/http/httptrace/otelhttptrace/clienttrace.go b/instrumentation/net/http/httptrace/otelhttptrace/clienttrace.go index 67e03f24810..353b9b33225 100644 --- a/instrumentation/net/http/httptrace/otelhttptrace/clienttrace.go +++ b/instrumentation/net/http/httptrace/otelhttptrace/clienttrace.go @@ -5,11 +5,15 @@ package otelhttptrace // import "go.opentelemetry.io/contrib/instrumentation/net import ( "context" + "crypto/sha256" "crypto/tls" + "encoding/base64" + "fmt" "net/http/httptrace" "net/textproto" "strings" "sync" + "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -36,6 +40,19 @@ var ( HTTPDNSAddrs = attribute.Key("http.dns.addrs") ) +// TLS attributes. +// +// From https://opentelemetry.io/docs/specs/semconv/attributes-registry/tls/ but only present from semconv v1.24. +var ( + TLSCipherKey = attribute.Key("tls.cipher") + TLSProtocolVersionKey = attribute.Key("tls.protocol.version") + TLSResumedKey = attribute.Key("tls.resumed") + TLSServerCertificateChainKey = attribute.Key("tls.server.certificate_chain") + TLSServerHashSha256Key = attribute.Key("tls.server.hash.sha256") + TLSServerNotAfterKey = attribute.Key("tls.server.not_after") + TLSServerNotBeforeKey = attribute.Key("tls.server.not_before") +) + var hookMap = map[string]string{ "http.dns": "http.getconn", "http.connect": "http.getconn", @@ -316,8 +333,29 @@ func (ct *clientTracer) tlsHandshakeStart() { ct.start("http.tls", "http.tls") } -func (ct *clientTracer) tlsHandshakeDone(_ tls.ConnectionState, err error) { - ct.end("http.tls", err) +func (ct *clientTracer) tlsHandshakeDone(state tls.ConnectionState, err error) { + attrs := make([]attribute.KeyValue, 0, 7) + attrs = append(attrs, + TLSCipherKey.String(tls.CipherSuiteName(state.CipherSuite)), + TLSProtocolVersionKey.String(tls.VersionName(state.Version)), + TLSResumedKey.Bool(state.DidResume), + ) + + if len(state.PeerCertificates) > 0 { + certChain := make([]string, len(state.PeerCertificates)) + for i, cert := range state.PeerCertificates { + certChain[i] = base64.StdEncoding.EncodeToString(cert.Raw) + } + + leafCert := state.PeerCertificates[0] + attrs = append(attrs, + TLSServerCertificateChainKey.StringSlice(certChain), + TLSServerHashSha256Key.String(fmt.Sprintf("%X", sha256.Sum256(leafCert.Raw))), + TLSServerNotAfterKey.String(leafCert.NotAfter.UTC().Format(time.RFC3339)), + TLSServerNotBeforeKey.String(leafCert.NotBefore.UTC().Format(time.RFC3339)), + ) + } + ct.end("http.tls", err, attrs...) } func (ct *clientTracer) wroteHeaderField(k string, v []string) { diff --git a/instrumentation/net/http/httptrace/otelhttptrace/httptrace_test.go b/instrumentation/net/http/httptrace/otelhttptrace/httptrace_test.go index faf81b85640..bd04f9c6aae 100644 --- a/instrumentation/net/http/httptrace/otelhttptrace/httptrace_test.go +++ b/instrumentation/net/http/httptrace/otelhttptrace/httptrace_test.go @@ -5,6 +5,7 @@ package otelhttptrace_test import ( "context" + "net" "net/http" "net/http/httptest" "strings" @@ -69,16 +70,16 @@ func TestRoundtrip(t *testing.T) { defer ts.Close() address := ts.Listener.Addr() - hp := strings.Split(address.String(), ":") + host, port, _ := net.SplitHostPort(address.String()) expectedAttrs = map[attribute.Key]string{ - semconv.NetHostNameKey: hp[0], - semconv.NetHostPortKey: hp[1], + semconv.NetHostNameKey: host, + semconv.NetHostPortKey: port, semconv.NetProtocolVersionKey: "1.1", semconv.HTTPMethodKey: "GET", semconv.HTTPSchemeKey: "http", semconv.HTTPTargetKey: "/", semconv.HTTPRequestContentLengthKey: "3", - semconv.NetSockPeerAddrKey: hp[0], + semconv.NetSockPeerAddrKey: host, semconv.NetTransportKey: "ip_tcp", semconv.UserAgentOriginalKey: "Go-http-client/1.1", } diff --git a/instrumentation/net/http/httptrace/otelhttptrace/test/clienttrace_test.go b/instrumentation/net/http/httptrace/otelhttptrace/test/clienttrace_test.go index 846f4fd128f..4d8b65e7b51 100644 --- a/instrumentation/net/http/httptrace/otelhttptrace/test/clienttrace_test.go +++ b/instrumentation/net/http/httptrace/otelhttptrace/test/clienttrace_test.go @@ -6,9 +6,14 @@ package test import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" + "fmt" "net/http" "net/http/httptest" "net/http/httptrace" + "net/url" + "slices" "testing" "time" @@ -42,107 +47,153 @@ func getSpansFromRecorder(sr *tracetest.SpanRecorder, name string) []trace.ReadO } func TestHTTPRequestWithClientTrace(t *testing.T) { - sr := tracetest.NewSpanRecorder() - tp := trace.NewTracerProvider(trace.WithSpanProcessor(sr)) - otel.SetTracerProvider(tp) - tr := tp.Tracer("httptrace/client") - - // Mock http server - ts := httptest.NewServer( + // Mock http server, one without TLS and another with TLS. + tsHTTP := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { }), ) - defer ts.Close() - address := ts.Listener.Addr() + defer tsHTTP.Close() - client := ts.Client() - err := func(ctx context.Context) error { - ctx, span := tr.Start(ctx, "test") - defer span.End() - req, _ := http.NewRequest("GET", ts.URL, nil) - _, req = otelhttptrace.W3C(ctx, req) + tsHTTPS := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }), + ) + defer tsHTTPS.Close() + + for _, ts := range []*httptest.Server{tsHTTP, tsHTTPS} { + sr := tracetest.NewSpanRecorder() + tp := trace.NewTracerProvider(trace.WithSpanProcessor(sr)) + otel.SetTracerProvider(tp) + tr := tp.Tracer("httptrace/client") + + err := func(ctx context.Context) error { + ctx, span := tr.Start(ctx, "test") + defer span.End() + req, _ := http.NewRequest("GET", ts.URL, nil) + _, req = otelhttptrace.W3C(ctx, req) + + res, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("Request failed: %s", err.Error()) + } + _ = res.Body.Close() - res, err := client.Do(req) + return nil + }(context.Background()) if err != nil { - t.Fatalf("Request failed: %s", err.Error()) + panic("unexpected error in http request: " + err.Error()) } - _ = res.Body.Close() - return nil - }(context.Background()) - if err != nil { - panic("unexpected error in http request: " + err.Error()) - } + type tc struct { + name string + attributes []attribute.KeyValue + parent string + } - testLen := []struct { - name string - attributes []attribute.KeyValue - parent string - }{ - { - name: "http.connect", - attributes: []attribute.KeyValue{ - attribute.Key("http.conn.done.addr").String(address.String()), - attribute.Key("http.conn.done.network").String("tcp"), - attribute.Key("http.conn.start.network").String("tcp"), - attribute.Key("http.remote").String(address.String()), + testLen := []tc{ + { + name: "http.connect", + attributes: []attribute.KeyValue{ + attribute.Key("http.conn.done.addr").String(ts.Listener.Addr().String()), + attribute.Key("http.conn.done.network").String("tcp"), + attribute.Key("http.conn.start.network").String("tcp"), + attribute.Key("http.remote").String(ts.Listener.Addr().String()), + }, + parent: "http.getconn", }, - parent: "http.getconn", - }, - { - name: "http.getconn", - attributes: []attribute.KeyValue{ - attribute.Key("http.remote").String(address.String()), - attribute.Key("net.host.name").String(address.String()), - attribute.Key("http.conn.reused").Bool(false), - attribute.Key("http.conn.wasidle").Bool(false), + { + name: "http.getconn", + attributes: []attribute.KeyValue{ + attribute.Key("http.remote").String(ts.Listener.Addr().String()), + attribute.Key("net.host.name").String(ts.Listener.Addr().String()), + attribute.Key("http.conn.reused").Bool(false), + attribute.Key("http.conn.wasidle").Bool(false), + }, + parent: "test", + }, + { + name: "http.receive", + parent: "test", + }, + { + name: "http.headers", + parent: "test", + }, + { + name: "http.send", + parent: "test", + }, + { + name: "test", }, - parent: "test", - }, - { - name: "http.receive", - parent: "test", - }, - { - name: "http.headers", - parent: "test", - }, - { - name: "http.send", - parent: "test", - }, - { - name: "test", - }, - } - for _, tl := range testLen { - span, ok := getSpanFromRecorder(sr, tl.name) - if !assert.True(t, ok) { - continue } - if tl.parent != "" { - parent, ok := getSpanFromRecorder(sr, tl.parent) - if assert.True(t, ok) { - assert.Equal(t, span.Parent().SpanID(), parent.SpanContext().SpanID()) - } + u, err := url.Parse(ts.URL) + if err != nil { + panic("unexpected error in parsing httptest server URL: " + err.Error()) + } + // http.tls only exists on HTTPS connections. + if u.Scheme == "https" { + testLen = append([]tc{{ + name: "http.tls", + attributes: []attribute.KeyValue{ + attribute.Key("tls.server.certificate_chain").StringSlice( + []string{base64.StdEncoding.EncodeToString(ts.Certificate().Raw)}, + ), + attribute.Key("tls.server.hash.sha256"). + String(fmt.Sprintf("%X", sha256.Sum256(ts.Certificate().Raw))), + attribute.Key("tls.server.not_after"). + String(ts.Certificate().NotAfter.UTC().Format(time.RFC3339)), + attribute.Key("tls.server.not_before"). + String(ts.Certificate().NotBefore.UTC().Format(time.RFC3339)), + }, + parent: "http.getconn", + }}, testLen...) } - if len(tl.attributes) > 0 { - attrs := span.Attributes() - if tl.name == "http.getconn" { - // http.local attribute uses a non-deterministic port. - local := attribute.Key("http.local") - var contains bool - for i, a := range attrs { - if a.Key == local { - attrs = append(attrs[:i], attrs[i+1:]...) - contains = true - break + + for i, tl := range testLen { + span, ok := getSpanFromRecorder(sr, tl.name) + if !assert.True(t, ok) { + continue + } + + if tl.parent != "" { + parent, ok := getSpanFromRecorder(sr, tl.parent) + if assert.True(t, ok) { + assert.Equal(t, span.Parent().SpanID(), parent.SpanContext().SpanID()) + } + } + if len(tl.attributes) > 0 { + attrs := span.Attributes() + if tl.name == "http.getconn" { + // http.local attribute uses a non-deterministic port. + local := attribute.Key("http.local") + var contains bool + for i, a := range attrs { + if a.Key == local { + attrs = append(attrs[:i], attrs[i+1:]...) + contains = true + break + } + } + assert.True(t, contains, "missing http.local attribute") + } + if tl.name == "http.tls" { + if i == 0 { + tl.attributes = append(tl.attributes, attribute.Key("tls.resumed").Bool(false)) + } else { + tl.attributes = append(tl.attributes, attribute.Key("tls.resumed").Bool(true)) } + attrs = slices.DeleteFunc(attrs, func(a attribute.KeyValue) bool { + // Skip keys that are unable to be detected beforehand. + if a.Key == otelhttptrace.TLSCipherKey || a.Key == otelhttptrace.TLSProtocolVersionKey { + return true + } + return false + }) } - assert.True(t, contains, "missing http.local attribute") + assert.ElementsMatch(t, tl.attributes, attrs) } - assert.ElementsMatch(t, tl.attributes, attrs) } } }