Skip to content

chore: Enhances User-Agent with additional metadata support #3463

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions internal/config/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,22 @@ type UAMetadata struct {
}

func (c *Config) NewClient(ctx context.Context) (any, error) {
// Network Logging transport is before Digest transport so it can log the first Digest requests with 401 Unauthorized.
// Terraform logging transport is after Digest transport so the Unauthorized request bodies are not logged.
// Transport chain (outermost to innermost):
// userAgentTransport -> tfLoggingTransport -> digestTransport -> networkLoggingTransport -> baseTransport
//
// This ordering ensures:
// 1. networkLoggingTransport logs ALL requests including digest auth 401 challenges
// 2. tfLoggingTransport only logs final authenticated requests (not sensitive auth details)
// 3. userAgentTransport modifies User-Agent before tfLoggingTransport logs it
networkLoggingTransport := NewTransportWithNetworkLogging(baseTransport, logging.IsDebugOrHigher())
digestTransport := digest.NewTransportWithHTTPRoundTripper(cast.ToString(c.PublicKey), cast.ToString(c.PrivateKey), networkLoggingTransport)
// Don't change logging.NewTransport to NewSubsystemLoggingHTTPTransport until all resources are in TPF.
tfLoggingTransport := logging.NewTransport("Atlas", digestTransport)
client := &http.Client{Transport: tfLoggingTransport}
// Add UserAgentExtra fields to the User-Agent header, see wrapper_provider_server.go
userAgentTransport := UserAgentTransport{
Transport: tfLoggingTransport,
}
client := &http.Client{Transport: &userAgentTransport}

optsAtlas := []matlasClient.ClientOpt{matlasClient.SetUserAgent(userAgent(c))}
if c.BaseURL != "" {
Expand Down
107 changes: 107 additions & 0 deletions internal/config/transport.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,119 @@
package config

import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
)

// UserAgentExtra holds additional metadata to be appended to the User-Agent header and context.
type UserAgentExtra struct {
Type string // Type of the operation (e.g., "Resource", "Datasource", etc.)
Name string // Full name, for example mongodbatlas_database_user
Operation string // GrpcCall for example, ReadResource, see wrapped_provider_server.go for details
ScriptLocation string // TODO: Support setting this field as opt-in on resources and datasources
}

// Combine returns a new UserAgentExtra by merging the receiver with another.
// Non-empty fields in 'other' take precedence over the receiver's fields.
func (e UserAgentExtra) Combine(other UserAgentExtra) UserAgentExtra {
typeName := e.Type
if other.Type != "" {
typeName = other.Type
}
name := e.Name
if other.Name != "" {
name = other.Name
}
operation := e.Operation
if other.Operation != "" {
operation = other.Operation
}
scriptLocation := e.ScriptLocation
if other.ScriptLocation != "" {
scriptLocation = other.ScriptLocation
}
return UserAgentExtra{
Type: typeName,
Name: name,
Operation: operation,
ScriptLocation: scriptLocation,
}
}

// ToHeaderValue returns a string representation suitable for use as a User-Agent header value.
// If oldHeader is non-empty, it is prepended to the new value.
func (e UserAgentExtra) ToHeaderValue(oldHeader string) string {
parts := []string{}
addPart := func(key, part string) {
if part == "" {
return
}
parts = append(parts, fmt.Sprintf("%s/%s", key, part))
}
addPart("Type", e.Type)
addPart("Name", e.Name)
addPart("Operation", e.Operation)
addPart("ScriptLocation", e.ScriptLocation)
newPart := strings.Join(parts, " ")
if oldHeader == "" {
return newPart
}
return fmt.Sprintf("%s %s", oldHeader, newPart)
}

type UserAgentKey string

const (
UserAgentExtraKey = UserAgentKey("user-agent-extra")
UserAgentHeader = "User-Agent"
)

// ReadUserAgentExtra retrieves the UserAgentExtra from the context if present.
// Returns a pointer to the UserAgentExtra, or nil if not set or of the wrong type.
// Logs a warning if the value is not of the expected type.
func ReadUserAgentExtra(ctx context.Context) *UserAgentExtra {
extra := ctx.Value(UserAgentExtraKey)
if extra == nil {
return nil
}
if userAgentExtra, ok := extra.(UserAgentExtra); ok {
return &userAgentExtra
}
log.Printf("[WARN] UserAgentExtra in context is not of type UserAgentExtra, got %v", extra)
return nil
}

// AddUserAgentExtra returns a new context with UserAgentExtra merged into any existing value.
// If a UserAgentExtra is already present in the context, the fields of 'extra' will override non-empty fields.
func AddUserAgentExtra(ctx context.Context, extra UserAgentExtra) context.Context {
oldExtra := ReadUserAgentExtra(ctx)
if oldExtra == nil {
return context.WithValue(ctx, UserAgentExtraKey, extra)
}
newExtra := oldExtra.Combine(extra)
return context.WithValue(ctx, UserAgentExtraKey, newExtra)
}

// UserAgentTransport wraps an http.RoundTripper to add User-Agent header with additional metadata.
type UserAgentTransport struct {
Transport http.RoundTripper
}

func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
extra := ReadUserAgentExtra(req.Context())
if extra != nil {
userAgent := req.Header.Get(UserAgentHeader)
newVar := extra.ToHeaderValue(userAgent)
req.Header.Set(UserAgentHeader, newVar)
}
resp, err := t.Transport.RoundTrip(req)
return resp, err
}

// NetworkLoggingTransport wraps an http.RoundTripper to provide enhanced logging
// for network operations, including timing, status codes, and error details.
type NetworkLoggingTransport struct {
Expand Down
89 changes: 89 additions & 0 deletions internal/config/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,95 @@ func TestNetworkLoggingTransport_Disabled(t *testing.T) {
assert.Empty(t, logStr, "Expected no logs when network logging is disabled")
}

func TestUserAgentExtra_ToHeaderValue(t *testing.T) {
testCases := map[string]struct {
extra config.UserAgentExtra
old string
expected string
}{
"all fields": {
extra: config.UserAgentExtra{
Type: "type1",
Name: "name1",
Operation: "op1",
ScriptLocation: "loc1",
},
old: "base/1.0",
expected: "base/1.0 Type/type1 Name/name1 Operation/op1 ScriptLocation/loc1",
},
"some fields empty": {
extra: config.UserAgentExtra{
Type: "",
Name: "name2",
Operation: "",
},
old: "",
expected: "Name/name2",
},
"none": {
extra: config.UserAgentExtra{},
old: "",
expected: "",
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got := tc.extra.ToHeaderValue(tc.old)
assert.Equal(t, tc.expected, got)
})
}
}

func TestUserAgentExtra_Combine(t *testing.T) {
testCases := map[string]struct {
base config.UserAgentExtra
other config.UserAgentExtra
expected config.UserAgentExtra
}{
"other overwrites non-empty": {
base: config.UserAgentExtra{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"},
other: config.UserAgentExtra{Type: "X", Name: "Y", Operation: "Z", ScriptLocation: "Q"},
expected: config.UserAgentExtra{Type: "X", Name: "Y", Operation: "Z", ScriptLocation: "Q"},
},
"other empty": {
base: config.UserAgentExtra{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"},
other: config.UserAgentExtra{},
expected: config.UserAgentExtra{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"},
},
"mixed": {
base: config.UserAgentExtra{Type: "A", Name: "B"},
other: config.UserAgentExtra{Name: "Y", ScriptLocation: "Q"},
expected: config.UserAgentExtra{Type: "A", Name: "Y", Operation: "", ScriptLocation: "Q"},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got := tc.base.Combine(tc.other)
assert.Equal(t, tc.expected, got)
})
}
}

func TestAddUserAgentExtra(t *testing.T) {
base := config.UserAgentExtra{Type: "A", Name: "B"}
other := config.UserAgentExtra{Name: "Y", ScriptLocation: "Q"}
ctx := config.AddUserAgentExtra(t.Context(), base)
ctx2 := config.AddUserAgentExtra(ctx, other)
// Should combine base and other
e := ctx2.Value(config.UserAgentExtraKey)
assert.NotNil(t, e)
ua := config.UserAgentExtra{}
if v, ok := e.(config.UserAgentExtra); ok {
ua = v
}
// The combined should have Type from base, Name from other, ScriptLocation from other
assert.Equal(t, "A", ua.Type)
assert.Equal(t, "Y", ua.Name)
assert.Equal(t, "Q", ua.ScriptLocation)
assert.Empty(t, ua.Operation)
}

func TestAccNetworkLogging(t *testing.T) {
acc.SkipInUnitTest(t)
acc.PreCheckBasic(t)
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ func MuxProviderFactory() func() tfprotov6.ProviderServer {
if err != nil {
log.Fatal(err)
}
return muxServer.ProviderServer
return NewWrappedProviderServer(muxServer.ProviderServer)
}

func MultiEnvDefaultFunc(ks []string, def any) any {
Expand Down
Loading