Skip to content
103 changes: 85 additions & 18 deletions cli/azd/internal/useragent.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,103 @@ package internal
import (
"fmt"
"os"
"runtime"
"strings"
)

// FormatUserAgent formats the user agent with its base information (azd/<version>), information
// that's included from the environment (`AZURE_DEV_USER_AGENT`) and any extra text passed
// via 'extra'.
func FormatUserAgent(extras []string) string {
const userSpecifiedAgentEnvironmentVariableName = "AZURE_DEV_USER_AGENT"
const githubActionsEnvironmentVariableName = "GITHUB_ACTIONS"

const azDevProductIdentifierKey = "azdev"
const templateProductIdentifierKey = "azdtempl"
const githubActionsProductIdentifierKey = "GhActions"

type UserAgent struct {
// Azure Developer CLI product identifier. Formatted as `azdev/<version>`
azDevCliIdentifier string

// (Optional) User specified identifier, set from `AZURE_DEV_USER_AGENT` environment variable
userSpecifiedIdentifier string

// (Optional) Identifier for the template used, if applicable. Formatted as `azdtempl/<version>`
templateIdentifier string

// (Optional) Identifier for GitHub Actions, if applicable
githubActionsIdentifier string
}

func (userAgent *UserAgent) String() string {
var sb strings.Builder
sb.WriteString(userAgent.azDevCliIdentifier)
appendIdentifier(&sb, userAgent.userSpecifiedIdentifier)
appendIdentifier(&sb, userAgent.templateIdentifier)
appendIdentifier(&sb, userAgent.githubActionsIdentifier)

return sb.String()
}

func appendIdentifier(sb *strings.Builder, identifier string) {
if identifier != "" {
sb.WriteString(" " + identifier)
}
}

func makeUserAgent(template string) UserAgent {
userAgent := UserAgent{}
userAgent.azDevCliIdentifier = getAzDevCliIdentifier()
userAgent.userSpecifiedIdentifier = getUserSpecifiedIdentifier()
userAgent.githubActionsIdentifier = getGithubActionsIdentifier()
userAgent.templateIdentifier = formatTemplateIdentifier(template)

return userAgent
}

// MakeUserAgentString creates a user agent string that contains all necessary product identifiers, in increasing order:
// - The Azure Developer CLI version, formatted as `azdev/<version>`
// - The user specified identifier, set from `AZURE_DEV_USER_AGENT` environment variable
// - The identifier for the template used, if applicable
// - The identifier for GitHub Actions, if applicable
// Examples (see test `TestUserAgentStringScenarios` for all scenarios ):
// - `azdev/1.0.0 (Go 1.18; windows/amd64)`
// - `azdev/1.0.0 (Go 1.18; windows/amd64) Custom-foo/1.0.0 azdtempl/my-template@1.0.0 GhActions`
func MakeUserAgentString(template string) string {
userAgent := makeUserAgent(template)

return userAgent.String()
}

func getAzDevCliIdentifier() string {
return fmt.Sprintf("%s/%s %s", azDevProductIdentifierKey, GetVersionNumber(), getPlatformInfo())
}

func getPlatformInfo() string {
return fmt.Sprintf("(Go %s; %s/%s)", runtime.Version(), runtime.GOOS, runtime.GOARCH)
}

func getUserSpecifiedIdentifier() string {
// like the Azure CLI (via it's `AZURE_HTTP_USER_AGENT` env variable) we allow for a user to append
// information to the UserAgent by setting an environment variable.
devUserAgent := os.Getenv("AZURE_DEV_USER_AGENT")

if devUserAgent != "" {
devUserAgent = " " + devUserAgent
if devUserAgent := os.Getenv(userSpecifiedAgentEnvironmentVariableName); devUserAgent != "" {
return devUserAgent
}

var appendUA string
return ""
}

if len(extras) > 0 {
appendUA = " " + strings.Join(extras, " ")
func getGithubActionsIdentifier() string {
// `GITHUB_ACTIONS` must be set to 'true' if running in GitHub Actions,
// see https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
if isRunningInGithubActions := os.Getenv(githubActionsEnvironmentVariableName); isRunningInGithubActions == "true" {
return githubActionsProductIdentifierKey
}

// and by default we always include azdev and our version number.
// Ex: AZURECLI/2.34.1 (DEB) <other things...> azdev/0.0.0-alpha.0 azdtempl/functestapp@v1
return fmt.Sprintf("azdev/%s%s%s", GetVersionNumber(), devUserAgent, appendUA)
return ""
}

// FormatTemplateForUserAgent formats the template into a UserAgent value.
func FormatTemplateForUserAgent(template string) string {
func formatTemplateIdentifier(template string) string {
if template == "" {
template = "[none]"
return ""
}
return fmt.Sprintf("azdtempl/%s", template)

return fmt.Sprintf("%s/%s", templateProductIdentifierKey, template)
}
135 changes: 120 additions & 15 deletions cli/azd/internal/useragent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,133 @@ import (
"github.com/stretchr/testify/require"
)

func TestGetUserAgent(t *testing.T) {
orig := os.Getenv("AZURE_DEV_USER_AGENT")
defer func() { os.Setenv("AZURE_DEV_USER_AGENT", orig) }()
func TestGetAzDevCliIdentifier(t *testing.T) {
version := GetVersionNumber()
require.NotEmpty(t, version)

require.Equal(t, fmt.Sprintf("%s/%s %s", azDevProductIdentifierKey, version, getPlatformInfo()), getAzDevCliIdentifier())
}

func TestUserSpecifiedAgentIdentifier(t *testing.T) {
devUserAgent := "MyAgent/1.0.0"
restorer := EnvironmentVariablesSetter(map[string]string{
userSpecifiedAgentEnvironmentVariableName: devUserAgent,
})

require.Equal(t, devUserAgent, getUserSpecifiedIdentifier())

// Empty case
os.Setenv(userSpecifiedAgentEnvironmentVariableName, "")
require.Equal(t, "", getUserSpecifiedIdentifier())

t.Cleanup(restorer)
}

func TestGithubActionIdentifier(t *testing.T) {
restorer := EnvironmentVariablesSetter(map[string]string{
githubActionsEnvironmentVariableName: "",
})

require.Equal(t, "", getGithubActionsIdentifier())

// Empty case
os.Setenv(githubActionsEnvironmentVariableName, "true")
require.Equal(t, "GhActions", getGithubActionsIdentifier())

t.Cleanup(restorer)
}

func TestFormatTemplate(t *testing.T) {
require.Equal(t, "", formatTemplateIdentifier(""))
require.Equal(t, fmt.Sprintf("%s/todo-python-mongo", templateProductIdentifierKey), formatTemplateIdentifier("todo-python-mongo"))
require.Equal(t, fmt.Sprintf("%s/todo-csharp-sql@0.0.1-beta", templateProductIdentifierKey), formatTemplateIdentifier("todo-csharp-sql@0.0.1-beta"))
}

// Scenario tests
func TestUserAgentStringScenarios(t *testing.T) {
restorer := EnvironmentVariablesSetter(map[string]string{
userSpecifiedAgentEnvironmentVariableName: "",
githubActionsEnvironmentVariableName: "",
})

version := GetVersionNumber()
require.NotEmpty(t, version)

os.Setenv("AZURE_DEV_USER_AGENT", "")
azDevIdentifier := fmt.Sprintf("azdev/%s %s", version, getPlatformInfo())

// Scenario: default agent
require.Equal(t, azDevIdentifier, MakeUserAgentString(""))

require.Equal(t, fmt.Sprintf("azdev/%s", version), FormatUserAgent([]string{}))
require.Equal(t, fmt.Sprintf("azdev/%s", version), FormatUserAgent(nil))
require.Equal(t, fmt.Sprintf("azdev/%s extra values", version), FormatUserAgent([]string{"extra", "values"}))
// Scenario: user specifies agent variable
os.Setenv(userSpecifiedAgentEnvironmentVariableName, "dev_user_agent")
require.Equal(t, fmt.Sprintf("%s dev_user_agent", azDevIdentifier), MakeUserAgentString(""))
os.Setenv(userSpecifiedAgentEnvironmentVariableName, "")

os.Setenv("AZURE_DEV_USER_AGENT", "dev_user_agent")
// Scenario: running on github actions
os.Setenv(githubActionsEnvironmentVariableName, "true")
require.Equal(t, fmt.Sprintf("%s GhActions", azDevIdentifier), MakeUserAgentString(""))
os.Setenv(githubActionsEnvironmentVariableName, "")

require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent", version), FormatUserAgent([]string{}))
require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent", version), FormatUserAgent(nil))
require.Equal(t, fmt.Sprintf("azdev/%s dev_user_agent extra values", version), FormatUserAgent([]string{"extra", "values"}))
// Scenario: template present
require.Equal(t, fmt.Sprintf("%s azdtempl/template@0.0.1", azDevIdentifier), MakeUserAgentString("template@0.0.1"))

// Scenario: full combination
os.Setenv(userSpecifiedAgentEnvironmentVariableName, "dev_user_agent")
os.Setenv(githubActionsEnvironmentVariableName, "true")
require.Equal(t, fmt.Sprintf("%s dev_user_agent azdtempl/template@0.0.1 GhActions", azDevIdentifier), MakeUserAgentString("template@0.0.1"))

t.Cleanup(restorer)
}

func TestFormatTemplateForUserAgent(t *testing.T) {
require.Equal(t, "azdtempl/[none]", FormatTemplateForUserAgent(""))
require.Equal(t, "azdtempl/todo-python-mongo", FormatTemplateForUserAgent("todo-python-mongo"))
require.Equal(t, "azdtempl/todo-csharp-sql@0.0.1-beta", FormatTemplateForUserAgent("todo-csharp-sql@0.0.1-beta"))
func TestUserAgentString(t *testing.T) {
userAgent := UserAgent{
azDevCliIdentifier: "cli/1.0.0",
}

require.Equal(t, userAgent.String(), userAgent.azDevCliIdentifier)

// Verify complete formatting
userAgent = UserAgent{
azDevCliIdentifier: "cli/1.0.0",
userSpecifiedIdentifier: "dev-user-agent/0.0.0-beta",
templateIdentifier: "template/mytemplate@2.0.0",
githubActionsIdentifier: "gh/3.0.2",
}

require.Equal(
t,
userAgent.String(),
fmt.Sprintf("%s %s %s %s", userAgent.azDevCliIdentifier, userAgent.userSpecifiedIdentifier, userAgent.templateIdentifier, userAgent.githubActionsIdentifier))
}

// EnvironmentVariablesSetter sets the provided environment variables,
// returning a function that restores the environment variables to their original values.
// Example usage:
// fn test(t *testing.T) {
// closer := helpers.EnvironmentVariablesSetter(map[string]string { "FOO_ENV": "Bar", "OTHER_FOO_ENV": "Bar2"})
// require.Equal(t, os.GetEnv("FOO_ENV"), "Bar")
// require.Equal(t, os.GetEnv("OTHER_FOO_ENV"), "Bar2")
// t.Cleanup(closer)
// }
//
func EnvironmentVariablesSetter(envContext map[string]string) func() {
restoreContext := map[string]string{}
for key, value := range envContext {
orig, present := os.LookupEnv(key)
if present {
restoreContext[key] = orig
}

os.Setenv(key, value)
}

return func() {
for key := range envContext {
if restoreValue, present := restoreContext[key]; present {
os.Setenv(key, restoreValue)
} else {
os.Unsetenv(key)
}
}
}
}
2 changes: 1 addition & 1 deletion cli/azd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ func fetchLatestVersion(version chan<- semver.Version) {
log.Printf("failed to create request object: %v, skipping update check", err)
}

req.Header.Set("User-Agent", internal.FormatUserAgent(nil))
req.Header.Set("User-Agent", internal.MakeUserAgentString(""))

res, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down
10 changes: 6 additions & 4 deletions cli/azd/pkg/commands/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ func GetAzCliFromContext(ctx context.Context) tools.AzCli {
azCli = tools.NewAzCli(azCliArgs)
}

selectedTemplate := ""

// Set the user agent if a template has been selected
template, ok := ctx.Value(environment.TemplateContextKey).(string)
if ok && strings.TrimSpace(template) != "" {
userAgent := internal.FormatTemplateForUserAgent(template)
azCli.SetUserAgent([]string{userAgent})
if template, ok := ctx.Value(environment.TemplateContextKey).(string); ok && strings.TrimSpace(template) != "" {
selectedTemplate = template
}

azCli.SetUserAgent(internal.MakeUserAgentString(selectedTemplate))

return azCli
}
8 changes: 4 additions & 4 deletions cli/azd/pkg/tools/azcli.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type AzCli interface {

// SetUserAgent sets the user agent that's sent with each call to the Azure
// CLI via the `AZURE_HTTP_USER_AGENT` environment variable.
SetUserAgent(extraUserAgentData []string)
SetUserAgent(userAgent string)
Comment thread
weikanglim marked this conversation as resolved.

// UserAgent gets the currently configured user agent
UserAgent() string
Expand Down Expand Up @@ -297,7 +297,7 @@ func NewAzCli(args NewAzCliArgs) AzCli {
}

return &azCli{
userAgent: azdinternal.FormatUserAgent(nil),
userAgent: azdinternal.MakeUserAgentString(""),
enableDebug: args.EnableDebug,
enableTelemetry: args.EnableTelemetry,
runWithResultFn: args.RunWithResultFn,
Expand Down Expand Up @@ -327,8 +327,8 @@ func (cli *azCli) CheckInstalled(_ context.Context) (bool, error) {

// SetUserAgent sets the user agent that's sent with each call to the Azure
// CLI via the `AZURE_HTTP_USER_AGENT` environment variable.
func (cli *azCli) SetUserAgent(userAgentData []string) {
cli.userAgent = azdinternal.FormatUserAgent(userAgentData)
func (cli *azCli) SetUserAgent(userAgent string) {
cli.userAgent = userAgent
}

func (cli *azCli) UserAgent() string {
Expand Down
16 changes: 5 additions & 11 deletions cli/azd/pkg/tools/azcli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"regexp"
"testing"

"github.com/azure/azure-dev/cli/azd/internal"
azdinternal "github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/executil"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -42,7 +43,7 @@ func TestAzCli(t *testing.T) {
require.NoError(t, err)

require.Equal(t, []string{
fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.FormatUserAgent(nil)),
fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.MakeUserAgentString("")),
}, env)

require.Equal(t, []string{"hello", "--debug"}, commandArgs)
Expand Down Expand Up @@ -71,7 +72,7 @@ func TestAzCli(t *testing.T) {
require.NoError(t, err)

require.Equal(t, []string{
"AZURE_HTTP_USER_AGENT=azdev/0.0.0-dev.0",
fmt.Sprintf("AZURE_HTTP_USER_AGENT=%s", azdinternal.MakeUserAgentString("")),
"AZURE_CORE_COLLECT_TELEMETRY=no",
}, env)

Expand All @@ -85,22 +86,15 @@ func TestAZCLIWithUserAgent(t *testing.T) {
EnableDebug: true,
})

tempAZCLI.SetUserAgent([]string{
Comment thread
weikanglim marked this conversation as resolved.
"AZTesting=yes",
})
tempAZCLI.SetUserAgent(internal.MakeUserAgentString("AZTesting=yes"))

azcli := tempAZCLI.(*azCli)

account := mustGetDefaultAccount(t, azcli)

userAgent := runAndCaptureUserAgent(t, azcli, account.Id)
require.Contains(t, userAgent, "AZTesting=yes")

// now disable telemetry, which doesn't appear to affect our user agent
azcli.enableTelemetry = false

userAgentWithTelemetryDisabled := runAndCaptureUserAgent(t, azcli, account.Id)
require.Contains(t, userAgentWithTelemetryDisabled, "AZTesting=yes")
require.Contains(t, userAgent, "azdev")
}

func mustGetDefaultAccount(t *testing.T, azcli AzCli) AzCliSubscriptionInfo {
Expand Down