...

Source file src/github.com/redhat-developer/odo/pkg/segment/segment.go

Documentation: github.com/redhat-developer/odo/pkg/segment

     1  package segment
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"net"
     9  	"os"
    10  	"os/user"
    11  	"path/filepath"
    12  	"regexp"
    13  	"runtime"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/Xuanwo/go-locale"
    18  
    19  	"github.com/redhat-developer/odo/pkg/config"
    20  	scontext "github.com/redhat-developer/odo/pkg/segment/context"
    21  
    22  	"github.com/google/uuid"
    23  	"golang.org/x/term"
    24  	"gopkg.in/segmentio/analytics-go.v3"
    25  	"k8s.io/klog"
    26  	"k8s.io/utils/pointer"
    27  
    28  	"github.com/redhat-developer/odo/pkg/preference"
    29  )
    30  
    31  // writekey will be the API key used to send data to the correct source on Segment. Default is the dev key
    32  var writeKey = "4xGV1HV7K2FtUWaoAozSBD7SNCBCJ65U"
    33  
    34  // Sanitizer replaces a PII data
    35  const Sanitizer = "XXXX"
    36  
    37  const TelemetryClient = "odo"
    38  
    39  // DisableTelemetryEnv is name of environment variable, if set to true it disables odo telemetry completely
    40  // hiding even the question
    41  const (
    42  	// DisableTelemetryEnv is name of environment variable, if set to true it disables odo telemetry completely.
    43  	// Setting it to false has the same effect as not setting it at all == does NOT enable telemetry!
    44  	// This has priority over TelemetryTrackingEnv
    45  	//
    46  	// Deprecated: Use TrackingConsentEnv instead.
    47  	DisableTelemetryEnv = "ODO_DISABLE_TELEMETRY"
    48  	// TrackingConsentEnv controls whether odo tracks telemetry or not.
    49  	// Setting it to 'no' has the same effect as DisableTelemetryEnv=true (telemetry is disabled and no question asked)
    50  	// Settings this to 'yes' skips the question about telemetry and enables user tracking.
    51  	// Possible values are yes/no.
    52  	TrackingConsentEnv = "ODO_TRACKING_CONSENT"
    53  )
    54  
    55  type TelemetryProperties struct {
    56  	Duration      int64                  `json:"duration"`
    57  	Error         string                 `json:"error"`
    58  	ErrorType     string                 `json:"errortype"`
    59  	Success       bool                   `json:"success"`
    60  	Tty           bool                   `json:"tty"`
    61  	Version       string                 `json:"version"`
    62  	CmdProperties map[string]interface{} `json:"cmdProperties"`
    63  }
    64  
    65  type TelemetryData struct {
    66  	Event      string              `json:"event"`
    67  	Properties TelemetryProperties `json:"properties"`
    68  }
    69  
    70  type Client struct {
    71  	// SegmentClient helps interact with the segment API
    72  	SegmentClient analytics.Client
    73  	// TelemetryFilePath points to the file containing anonymousID used for tracking odo commands executed by the user
    74  	TelemetryFilePath string
    75  }
    76  
    77  func GetApikey() string {
    78  	return writeKey
    79  }
    80  
    81  // NewClient returns a Client created with the default args
    82  func NewClient() (*Client, error) {
    83  	return newCustomClient(
    84  		GetTelemetryFilePath(),
    85  		analytics.DefaultEndpoint,
    86  	)
    87  }
    88  
    89  // newCustomClient returns a Client created with custom args
    90  func newCustomClient(telemetryFilePath string, segmentEndpoint string) (*Client, error) {
    91  	// get the locale information
    92  	tag, err := locale.Detect()
    93  	if err != nil {
    94  		klog.V(4).Infof("couldn't fetch locale info: %s", err.Error())
    95  	}
    96  	// DefaultContext has IP set to 0.0.0.0 so that it does not track user's IP, which it does in case no IP is set
    97  	client, err := analytics.NewWithConfig(writeKey, analytics.Config{
    98  		Endpoint: segmentEndpoint,
    99  		Verbose:  true,
   100  		DefaultContext: &analytics.Context{
   101  			IP:       net.IPv4(0, 0, 0, 0),
   102  			Timezone: getTimeZoneRelativeToUTC(),
   103  			OS: analytics.OSInfo{
   104  				Name: runtime.GOOS,
   105  			},
   106  			Locale: tag.String(),
   107  		},
   108  	})
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  	return &Client{
   113  		SegmentClient:     client,
   114  		TelemetryFilePath: telemetryFilePath,
   115  	}, nil
   116  }
   117  
   118  // getTimeZoneRelativeToUTC returns time zone relative to UTC (UTC +0530, UTC 0000, etc.)
   119  func getTimeZoneRelativeToUTC() string {
   120  	// t is a string array of RHC8222Z representation of current time
   121  	// example - [13 Sep 21 16:37 +0530]
   122  	var t = strings.Split(time.Now().Format(time.RFC822Z), " ")
   123  	return fmt.Sprintf("UTC %s", t[len(t)-1])
   124  }
   125  
   126  // Close client connection and send the data
   127  func (c *Client) Close() error {
   128  	return c.SegmentClient.Close()
   129  }
   130  
   131  // Upload prepares the data to be sent to segment and send it once the client connection closes
   132  func (c *Client) Upload(ctx context.Context, data TelemetryData) error {
   133  	// if the user has not consented for telemetry, return
   134  	if !scontext.GetTelemetryStatus(ctx) {
   135  		return nil
   136  	}
   137  
   138  	// obtain the user ID
   139  	userId, uerr := GetUserIdentity(c.TelemetryFilePath)
   140  	if uerr != nil {
   141  		return uerr
   142  	}
   143  
   144  	// add information to the data
   145  	properties := analytics.NewProperties()
   146  	for k, v := range data.Properties.CmdProperties {
   147  		if k != scontext.TelemetryStatus {
   148  			properties = properties.Set(k, v)
   149  		}
   150  	}
   151  
   152  	properties = properties.Set("version", data.Properties.Version).
   153  		Set("success", data.Properties.Success).
   154  		Set("duration(ms)", data.Properties.Duration).
   155  		Set("tty", data.Properties.Tty)
   156  	// in case the command executed unsuccessfully, add information about the error in the data
   157  	if data.Properties.Error != "" {
   158  		properties = properties.Set("error", data.Properties.Error).Set("error-type", data.Properties.ErrorType)
   159  	}
   160  
   161  	// send the Identify message data that helps identify the user on segment
   162  	err := c.SegmentClient.Enqueue(analytics.Identify{
   163  		UserId: userId,
   164  		Traits: addConfigTraits(),
   165  	})
   166  	if err != nil {
   167  		klog.V(4).Infof("Cannot send Identify telemetry event: %q", err)
   168  		// This doesn't have to be a fatal error, as we can still try to track normal event
   169  		// There just might be some missing information about the user, but this will be only
   170  		// in case that this was the first time we tried to send identify event for give userId.
   171  	}
   172  
   173  	// queue the data that has telemetry information
   174  	return c.SegmentClient.Enqueue(analytics.Track{
   175  		UserId:     userId,
   176  		Event:      data.Event,
   177  		Properties: properties,
   178  	})
   179  }
   180  
   181  // addConfigTraits adds information about the system
   182  func addConfigTraits() analytics.Traits {
   183  	traits := analytics.NewTraits().Set("os", runtime.GOOS)
   184  	traits.Set("timezone", getTimeZoneRelativeToUTC())
   185  	// get the locale information
   186  	tag, err := locale.Detect()
   187  	if err != nil {
   188  		klog.V(4).Infof("couldn't fetch locale info: %s", err.Error())
   189  	} else {
   190  		traits.Set("locale", tag.String())
   191  	}
   192  	return traits
   193  }
   194  
   195  // GetTelemetryFilePath returns the default file path where the generated anonymous ID is stored
   196  func GetTelemetryFilePath() string {
   197  	homeDir, _ := os.UserHomeDir()
   198  	return filepath.Join(homeDir, ".redhat", "anonymousId")
   199  }
   200  
   201  // GetUserIdentity returns the anonymous ID if it exists, else creates a new one and sends the data to Segment
   202  func GetUserIdentity(telemetryFilePath string) (string, error) {
   203  	var id []byte
   204  
   205  	// Get-or-Create the '$HOME/.redhat' directory
   206  	if err := os.MkdirAll(filepath.Dir(telemetryFilePath), os.ModePerm); err != nil {
   207  		return "", err
   208  	}
   209  
   210  	// Get-or-Create the anonymousID file that contains a UUID
   211  	if _, err := os.Stat(telemetryFilePath); !os.IsNotExist(err) {
   212  		id, err = os.ReadFile(telemetryFilePath)
   213  		if err != nil {
   214  			return "", err
   215  		}
   216  	}
   217  
   218  	// check if the id is a valid uuid, if not, generates a new one and writes to file
   219  	if _, err := uuid.ParseBytes(bytes.TrimSpace(id)); err != nil {
   220  		u, uErr := uuid.NewRandom()
   221  		if uErr != nil {
   222  			return "", fmt.Errorf("failed to generate anonymous ID for telemetry: %w", uErr)
   223  		}
   224  		id = []byte(u.String())
   225  		if err := os.WriteFile(telemetryFilePath, id, 0o600); err != nil {
   226  			return "", err
   227  		}
   228  	}
   229  	return string(bytes.TrimSpace(id)), nil
   230  }
   231  
   232  // SetError sanitizes any PII(Personally Identifiable Information) from the error
   233  func SetError(err error) (errString string) {
   234  	if err == nil {
   235  		return ""
   236  	}
   237  	errString = err.Error()
   238  
   239  	// Sanitize user information
   240  	errString = sanitizeUserInfo(errString)
   241  
   242  	// Sanitize file path
   243  	errString = sanitizeFilePath(errString)
   244  
   245  	// Sanitize exec commands: For errors when a command exec fails in cases like odo exec or odo test, we do not want to know the command that the user executed, so we simply return
   246  	errString = sanitizeExec(errString)
   247  
   248  	// Sanitize URL
   249  	errString = sanitizeURL(errString)
   250  
   251  	return errString
   252  }
   253  
   254  // ErrorType returns the type of error
   255  func ErrorType(err error) string {
   256  	if err == nil {
   257  		return ""
   258  	}
   259  	wrappedErr := errors.Unwrap(err)
   260  	if wrappedErr != nil {
   261  		return fmt.Sprintf("%T", wrappedErr)
   262  	}
   263  	return fmt.Sprintf("%T", err)
   264  }
   265  
   266  // RunningInTerminal checks if odo was run from a terminal
   267  func RunningInTerminal() bool {
   268  	return term.IsTerminal(int(os.Stdin.Fd()))
   269  }
   270  
   271  // IsTelemetryEnabled returns true if user has consented to telemetry
   272  func IsTelemetryEnabled(cfg preference.Client, envConfig config.Configuration) bool {
   273  	klog.V(4).Info("Checking telemetry enable status")
   274  	// The env variable gets precedence in this decision.
   275  	// In case a non-bool value was passed to the env var, we ignore it
   276  
   277  	//lint:ignore SA1019 We deprecated this env var, but until it is removed, we still need to support it
   278  	disableTelemetry := pointer.BoolDeref(envConfig.OdoDisableTelemetry, false)
   279  	if disableTelemetry {
   280  		//lint:ignore SA1019 We deprecated this env var, but until it is removed, we still need to support it
   281  		klog.V(4).Infof("Sending telemetry disabled by %q env variable\n", DisableTelemetryEnv)
   282  		return false
   283  	}
   284  
   285  	_, trackingConsentEnabled, present, err := IsTrackingConsentEnabled(&envConfig)
   286  	if err != nil {
   287  		klog.V(4).Infof("error in determining value of tracking consent env var: %v", err)
   288  	} else if present {
   289  		//Takes precedence over the ConsentTelemetry preference
   290  		if !trackingConsentEnabled {
   291  			klog.V(4).Info("Sending telemetry disabled by env variable\n")
   292  			return false
   293  		}
   294  		klog.V(4).Info("Sending telemetry enabled by env variable\n")
   295  		return true
   296  	}
   297  
   298  	isEnabled := cfg.GetConsentTelemetry()
   299  	s := "Sending telemetry disabled by preference"
   300  	if isEnabled {
   301  		s = "Sending telemetry enabled by preference"
   302  	}
   303  	klog.V(4).Infof("%s\n", s)
   304  	return isEnabled
   305  }
   306  
   307  // IsTrackingConsentEnabled returns whether tracking consent is enabled, based on the value of the TrackingConsentEnv environment variable.
   308  // The second value returned indicates whether the variable is present in the environment.
   309  func IsTrackingConsentEnabled(envConfig *config.Configuration) (value string, enabled bool, present bool, err error) {
   310  	if envConfig.OdoTrackingConsent == nil {
   311  		return "", false, false, nil
   312  	}
   313  	trackingConsent := *envConfig.OdoTrackingConsent
   314  	switch trackingConsent {
   315  	case "yes":
   316  		return trackingConsent, true, true, nil
   317  	case "no":
   318  		return trackingConsent, false, true, nil
   319  	default:
   320  		return trackingConsent, false, true, fmt.Errorf("invalid value for %s: %q", TrackingConsentEnv, trackingConsent)
   321  	}
   322  }
   323  
   324  // sanitizeUserInfo sanitizes username from the error string
   325  func sanitizeUserInfo(errString string) string {
   326  	user1, err1 := user.Current()
   327  	if err1 != nil {
   328  		return err1.Error()
   329  	}
   330  	errString = strings.ReplaceAll(errString, user1.Username, Sanitizer)
   331  	return errString
   332  }
   333  
   334  // sanitizeFilePath sanitizes file paths from error string
   335  func sanitizeFilePath(errString string) string {
   336  	for _, str := range strings.Split(errString, " ") {
   337  		if strings.Count(str, string(os.PathSeparator)) > 1 {
   338  			errString = strings.ReplaceAll(errString, str, Sanitizer)
   339  		}
   340  	}
   341  	return errString
   342  }
   343  
   344  // sanitizeURL sanitizes URLs from the error string
   345  func sanitizeURL(errString string) string {
   346  	// the following regex parses hostnames and ip addresses
   347  	// references - https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
   348  	// https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html
   349  	urlPattern, err := regexp.Compile(`((https?|ftp|smtp)://)?((?:[0-9]{1,3}\.){3}[0-9]{1,3}(:([0-9]{1,5}))?|([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,})`)
   350  	if err != nil {
   351  		return errString
   352  	}
   353  	errString = urlPattern.ReplaceAllString(errString, Sanitizer)
   354  	return errString
   355  }
   356  
   357  // sanitizeExec sanitizes commands from the error string that might have been executed by users while running commands like odo test or odo exec
   358  func sanitizeExec(errString string) string {
   359  	pattern, _ := regexp.Compile("exec command.*")
   360  	errString = pattern.ReplaceAllString(errString, fmt.Sprintf("exec command %s", Sanitizer))
   361  	return errString
   362  }
   363  

View as plain text