OpenTelemetry instrumentation for Go's networking primitives.
This package provides tracing wrappers for dial functions and DNS resolvers, giving you visibility into what happens during connection establishment. It captures DNS resolution time, TCP handshake duration, and the actual IP addresses your application connects to.
When you instrument an HTTP client with OpenTelemetry, you typically get a single span covering the entire request. While useful, this hides important details about what's happening at the network level:
- DNS resolution: How long did it take to resolve the hostname? Did the resolver return multiple addresses? Which IP version was used?
- Connection establishment: Which IP address did the client actually connect to? How long did the TCP handshake take? Did the client try multiple addresses before succeeding?
- Failure diagnosis: When a connection fails, was it a DNS problem or a TCP timeout? If multiple addresses were returned, did all of them fail?
This information is critical for debugging latency issues, understanding network behavior, and diagnosing intermittent failures. A slow DNS resolver can add hundreds of milliseconds to your request latency. A server with multiple A records might have one unhealthy endpoint causing retries. IPv6 connectivity issues might be silently falling back to IPv4.
otelnet fills this observability gap by instrumenting the low-level dial and resolver functions that HTTP clients and other network code use internally. You get dedicated spans for DNS lookup, dial, and connection attempts, each with semantic attributes following OpenTelemetry conventions.
go get github.com/firetiger-oss/otelnet@latestThe most common use case is wrapping the dial function used by
http.Transport:
package main
import (
"net"
"net/http"
"github.com/firetiger-oss/otelnet"
)
func main() {
// Wrap the default resolver with tracing
resolver := otelnet.NewResolver(net.DefaultResolver)
// Create an instrumented dial function
dialer := &net.Dialer{}
dialFunc := otelnet.DialFunc(dialer.DialContext, otelnet.WithResolver(resolver))
// Use it in your HTTP transport
transport := &http.Transport{
DialContext: dialFunc,
}
client := &http.Client{Transport: transport}
// Requests made with this client will now produce detailed network spans
resp, err := client.Get("https://example.com")
// ...
}If you only want connection tracing without DNS resolution spans, don't pass a resolver:
dialFunc := otelnet.DialFunc(dialer.DialContext)In this mode, DNS resolution still happens (via the underlying dial function),
but no dns.lookup span is created. This is useful when connecting to IP
addresses directly or when you want less verbose traces.
You can wrap any resolver that implements the Resolver interface:
type Resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}This allows you to instrument custom DNS clients or caching resolvers:
customResolver := &MyCustomResolver{}
tracingResolver := otelnet.NewResolver(customResolver)The instrumentation creates the following spans:
dns.lookup (when using a tracing resolver)
dns.question.name: The hostname being resolveddns.answer.count: Number of IP addresses returned
net.dial (parent span for the connection attempt)
destination.address: The target hostname or IPdestination.port: The target portnetwork.local.address: Local address after connectionnetwork.local.port: Local port after connectionnetwork.peer.address: Remote address after connectionnetwork.peer.port: Remote port after connectionnetwork.transport:tcporudpnetwork.type:ipv4oripv6
net.connect (child span for each connection attempt)
network.peer.address: The IP address being connected tonetwork.peer.port: The port being connected to
When a hostname resolves to multiple IP addresses, you'll see multiple
net.connect spans as children of the net.dial span, one for each address
tried until a connection succeeds.