Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

otelhttptrace: add TLS info to the "http.tls" span #5563

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 40 additions & 2 deletions instrumentation/net/http/httptrace/otelhttptrace/clienttrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package otelhttptrace_test

import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
host, port, _ := net.SplitHostPort(address.String())
host, port, err := net.SplitHostPort(address.String())
if err != nil {
t.Fatalf("Address splitting failed: %s", err.Error())
}

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",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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} {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're taking the approach of subtests, I think it would be better to loop through anonymous structs with a createServer method which creates the proper (HTTP/HTTPS) server, rather than initializing them both.

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" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There shouldn't be conditions like this in a subtest loop. These should be part of the attributes defined in the loop definition.

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)
}
}
}
Expand Down
Loading