// Package metriton implements submitting telemetry data to the Metriton database. // // Metriton replaced Scout, and was originally going to have its own telemetry API and a // Scout-compatibility endpoint during the migration. But now the Scout-compatible API is // the only thing we use. // // See also: The old scout.py package / // . // // Things that are in scout.py, but are intentionally left of this package: // - automatically setting the HTTP client user-agent string // - an InstallIDFromConfigMap getter package metriton import ( "context" "errors" "net/http" "net/url" "os" "strconv" "strings" "sync" ) var ( // The endpoint you should use by default DefaultEndpoint = "https://metriton.datawire.io/scout" // Use BetaEndpoint for testing purposes without polluting production data BetaEndpoint = "https://metriton.datawire.io/beta/scout" // ScoutPyEndpoint is the default endpoint used by scout.py; it obeys the // SCOUT_HOST and SCOUT_HTTPS environment variables. I'm not sure when you should // use it instead of DefaultEndpoint, but I'm putting it in Go so that I never // have to look at scout.py again. ScoutPyEndpoint = endpointFromEnv() ) func getenvDefault(varname, def string) string { ret := os.Getenv(varname) if ret == "" { ret = def } return ret } func endpointFromEnv() string { host := getenvDefault("SCOUT_HOST", "metriton.datawire.io") useHTTPS, _ := strconv.ParseBool(getenvDefault("SCOUT_HTTPS", "1")) ret := &url.URL{ Scheme: "http", Host: host, Path: "/scout", } if useHTTPS { ret.Scheme = "https" } return ret.String() } // Reporter is a client to type Reporter struct { // Information about the application submitting telemetry. Application string Version string // GetInstallID is a function, instead of a fixed 'InstallID' string, in order to // facilitate getting it lazily; and possibly updating the BaseMetadata based on // the journey to getting the install ID. See StaticInstallID and // InstallIDFromFilesystem. GetInstallID func(*Reporter) (string, error) // BaseMetadata will be merged in to the data passed to each call to .Report(). // If the data passed to .Report() and BaseMetadata have a key in common, the // value passed as an argument to .Report() wins. BaseMetadata map[string]interface{} // The HTTP client used to to submit the request; if this is nil, then // http.DefaultClient is used. Client *http.Client // The endpoint URL to submit to; if this is empty, then DefaultEndpoint is used. Endpoint string mu sync.Mutex initialized bool disabled bool installID string } func (r *Reporter) ensureInitialized() error { if r.Application == "" { return errors.New("Reporter.Application may not be empty") } if r.Version == "" { return errors.New("Reporter.Version may not be empty") } if r.GetInstallID == nil { return errors.New("Reporter.GetInstallID may not be nil") } if r.initialized { return nil } if r.BaseMetadata == nil { r.BaseMetadata = make(map[string]interface{}) } r.disabled = IsDisabledByUser() installID, err := r.GetInstallID(r) if err != nil { return err } r.installID = installID r.initialized = true return nil } // IsDisabledByUser returns whether telemetry reporting is disabled by the user. func IsDisabledByUser() bool { // From scout.py if strings.HasPrefix(os.Getenv("TRAVIS_REPO_SLUG"), "datawire/") { return true } // This mimics the existing ad-hoc Go clients, rather than scout.py; it is a more // sensitive trigger than scout.py's __is_disabled() (which only accepts "1", // "true", "yes"; case-insensitive). return os.Getenv("SCOUT_DISABLE") != "" } func (r *Reporter) InstallID() string { r.mu.Lock() defer r.mu.Unlock() _ = r.ensureInitialized() return r.installID } // Report submits a telemetry report to Metriton. It is safe to call .Report() from // different goroutines. It is NOT safe to mutate the public fields in the Reporter while // .Report() is being called. func (r *Reporter) Report(ctx context.Context, metadata map[string]interface{}) (*Response, error) { r.mu.Lock() if err := r.ensureInitialized(); err != nil { r.mu.Unlock() return nil, err } var resp *Response var err error if r.disabled { r.mu.Unlock() } else { client := r.Client if client == nil { client = http.DefaultClient } endpoint := r.Endpoint if endpoint == "" { endpoint = DefaultEndpoint } mergedMetadata := make(map[string]interface{}, len(r.BaseMetadata)+len(metadata)) // FWIW, the resolution of conflicts between 'r.BaseMetadata' and 'metadata' // mimics scout.py; I'm not sure whether that aspect of scout.py's API is // intentional or incidental. for k, v := range r.BaseMetadata { mergedMetadata[k] = v } for k, v := range metadata { mergedMetadata[k] = v } report := Report{ Application: r.Application, InstallID: r.installID, Version: r.Version, Metadata: mergedMetadata, } r.mu.Unlock() resp, err = report.Send(ctx, client, endpoint) if err != nil { return nil, err } } if resp == nil { // This mimics scout.py resp = &Response{ AppInfo: AppInfo{ LatestVersion: r.Version, }, } } if resp != nil && resp.DisableScout { r.mu.Lock() r.disabled = true r.mu.Unlock() } return resp, nil }