Skip to content

[Bug]: debug logging disables Go linker dead code elimination in consumers #1022

Description

@hakman

go Version

go version go1.26.5 linux/arm64

API Wrapper Version

github.com/linode/linodego/v2 v2.5.0

Code Snippet

// client.go (since #938)
var (
	reqLogTemplate  = template.Must(template.New("request").Parse(requestTemplateStr))
	respLogTemplate = template.Must(template.New("response").Parse(responseTemplateStr))
)

func (c *Client) logRequest(req *http.Request) *http.Request {
	// ...
	err := reqLogTemplate.Execute(&logBuf, map[string]any{
		// Flat string values only.
	})
	// ...
}

Expected Behavior

Importing linodego should not disable Go linker optimizations for the consuming binary. The templates only access flat keys such as {{.Request}}, {{.Host}}, and {{.Status}} from a map[string]any containing strings. They do not require reflection-based method lookup, and fmt.Fprintf can produce byte-for-byte identical output.

Actual Behavior

Since #938 removed resty, client.go parses two embedded templates during package initialization and executes them in logRequest and logResponse. The text/template execution engine calls reflect.Value.MethodByName with a non-constant argument, which the compiler marks REFLECTMETHOD.

When this call is statically reachable, the linker retains every exported method of every reachable type instead of pruning unused methods. This behavior is documented for [reflect.Value.MethodByName](https://pkg.go.dev/reflect#Value.MethodByName) and discussed in [golang/go#72895](golang/go#72895).

Runtime logging settings do not affect static reachability, so every binary that constructs a linodego.Client incurs the cost even when debug logging is disabled.

The cost scales with the consumer's dependency tree. The README example pays 17%. In kOps, which links linodego alongside other cloud SDKs, this call site is one of the last two blockers keeping kops-controller at 150.9 MB instead of 69.0 MB, a 54% reduction. Most retained code consists of unused methods from AWS, GCP, Azure SDKs, and client-go.

Steps to Reproduce

  1. Save the README "Getting started" example as main.go, then initialize the module:

    go mod init repro
    go mod tidy
  2. Build it and inspect the linker reachability dump:

    GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o repro .
    go build -ldflags=-dumpdep -o /dev/null . 2>&1 | grep '<ReflectMethod>'

    The dump shows text/template.(*state).evalField <ReflectMethod>, reached through (*Client).logRequest. The binary is 7,667,874 bytes.

  3. Replace the two Template.Execute calls in client.go with equivalent fmt.Fprintf calls, point the module to the patched copy with a replace directive, and rebuild.

    The <ReflectMethod> symbols disappear, and the example shrinks from 7.6 MB to 6.3 MB. Savings increase with the size of the consumer's dependency tree.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugissues that report a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions