14 October 2024

Versioning User-Agent Headers in Go

In order to get an idea of usage of our client library, it’s common for client libraries to define a custom User-Agent header to identify what versions of the library are making network requests. While the header can be defined as a constant string with the version getting changed on each release, this is error-prone because it requires manual changes to keep the version up-to-date. For example, for our the initial implementation of User-Agent headers in planetscale-go, we defined a libraryVersion constant, which was then interpolated into a userAgent constant. As shown below.

const (
    libraryVersion = "v0.67.0"
    userAgent      = "planetscale-go/" + libraryVersion
)

// Variadic option for configuring the `User-Agent` header.
func WithUserAgent(userAgent string) ClientOption {
    return func(c *Client) error {
        c.UserAgent = fmt.Sprintf("%s %s", userAgent, c.UserAgent)
        return nil
    }
}

func (c *Client) newRequest(...) {
    // ...
    req.Header.Set("User-Agent", c.UserAgent)
    // ...
}

Needless to say, after the initial implementation, nobody (including myself) remembered to update that libraryVersion constant. And it went uncaught for two years until we needed to look at these requests for something and we noticed that the User-Agent header was still stuck at v0.67.0. In reality, the latest version was v0.110.0, so a lot of changes had occurred since then.

Noticing this, I realized that I needed to correct my mistake and improve upon this. After chatting with a coworker, he mentioned that Go implemented first-class support for embedding version information within binaries, so that sounded promising. After some searching, I found out that Go 1.18 added the debug.BuildInfo struct, which populates a lot of information about the Go program, such as the version, checksum, and even Git commit SHAs. Within that struct, there is also the Deps field, which contains similar information about the dependencies of the program, including planetscale-go.

With this new knowledge, I changed how the default User-Agent was constructed. Instead of a constant string, we could just find the planetscale-go dependency and check the version from there. We wrap this in a sync.OnceValue because calling debug.ReadBuildInfo() is expensive.

var defaultUserAgent = sync.OnceValue(func() string {
    libraryVersion := "unknown"
    buildInfo, ok := debug.ReadBuildInfo()
    if ok {
        for _, dep := range buildInfo.Deps {
            if dep.Path == "github.com/planetscale/planetscale-go" {
                libraryVersion = dep.Version
                break
            }
        }
    }

    return "planetscale-go/" + libraryVersion
})

This gives us a more accurate User-Agent header, taking out manual changes in order to make sure it is up-to-date. If you define a specific version of a module like planetscale-go@v0.111.0, that version will be defined in the header. Note, we have to use the Deps module in this case because planetscale-go does not have a binary and is never built, it is imported for usage in other Go programs, such as the PlanetScale CLI. If we were to use the Main field from debug.BuildInfo, that would give us information about the Go program that is using planetscale-go, not planetscale-go itself. You can see the full pull request here.

If you wanted to use this approach in your own code, if it is a binary, you can check the Version from within debug.BuildInfo.Main if your program is being compiled into a binary. Otherwise, you can use snippet above, but ensuring that you are comparing dep.Path to your Go module’s path. It’s as simple as that. Happy coding!

Did you enjoy reading this?

Feel free to let me know on Twitter. You can also subscribe to my newsletter for more of my writing.