diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 651f54051d..05641be0d1 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -6,6 +6,10 @@ import ( configMediator "github.com/ActiveState/cli/internal/mediators/config" ) +func init() { + configMediator.RegisterOption(constants.AnalyticsPixelOverrideConfig, configMediator.String, "") +} + // Dispatcher describes a struct that can send analytics event in the background type Dispatcher interface { Event(category, action string, dim ...*dimensions.Values) diff --git a/internal/analytics/client/sync/client.go b/internal/analytics/client/sync/client.go index 9c9cdf8638..e87bc308ed 100644 --- a/internal/analytics/client/sync/client.go +++ b/internal/analytics/client/sync/client.go @@ -123,12 +123,12 @@ func New(source string, cfg *config.Instance, auth *authentication.Auth, out out // Register reporters if condition.InTest() { logging.Debug("Using test reporter") - a.NewReporter(reporters.NewTestReporter(reporters.TestReportFilepath())) + a.NewReporter(reporters.NewTestReporter(reporters.TestReportFilepath(), a.cfg)) logging.Debug("Using test reporter as instructed by env") } else if v := os.Getenv(constants.AnalyticsLogEnvVarName); v != "" { - a.NewReporter(reporters.NewTestReporter(v)) + a.NewReporter(reporters.NewTestReporter(v, a.cfg)) } else { - a.NewReporter(reporters.NewPixelReporter()) + a.NewReporter(reporters.NewPixelReporter(a.cfg)) } return a diff --git a/internal/analytics/client/sync/reporters/pixel.go b/internal/analytics/client/sync/reporters/pixel.go index d42588862b..3b35474a37 100644 --- a/internal/analytics/client/sync/reporters/pixel.go +++ b/internal/analytics/client/sync/reporters/pixel.go @@ -7,22 +7,26 @@ import ( "os" "github.com/ActiveState/cli/internal/analytics/dimensions" + "github.com/ActiveState/cli/internal/config" "github.com/ActiveState/cli/internal/constants" "github.com/ActiveState/cli/internal/errs" + + configMediator "github.com/ActiveState/cli/internal/mediators/config" ) type PixelReporter struct { url string } -func NewPixelReporter() *PixelReporter { - var pixelUrl string - - // Attempt to get the value for the pixel URL from the environment. Fall back to default if that fails - if pixelUrl = os.Getenv(constants.AnalyticsPixelOverrideEnv); pixelUrl == "" { - pixelUrl = constants.DefaultAnalyticsPixel +func NewPixelReporter(cfg *config.Instance) *PixelReporter { + reporter := &PixelReporter{ + url: sourcePixelURL(cfg), } - return &PixelReporter{pixelUrl} + + configMediator.AddListener(constants.AnalyticsPixelOverrideConfig, func() { + reporter.url = sourcePixelURL(cfg) + }) + return reporter } func (r *PixelReporter) ID() string { @@ -55,3 +59,23 @@ func (r *PixelReporter) Event(category, action, source, label string, d *dimensi return nil } + +func sourcePixelURL(cfg *config.Instance) string { + var ( + pixelUrl string + + envUrl = os.Getenv(constants.AnalyticsPixelOverrideEnv) + cfgUrl = cfg.GetString(constants.AnalyticsPixelOverrideConfig) + ) + + switch { + case envUrl != "": + pixelUrl = envUrl + case cfgUrl != "": + pixelUrl = cfgUrl + default: + pixelUrl = constants.DefaultAnalyticsPixel + } + + return pixelUrl +} diff --git a/internal/analytics/client/sync/reporters/test.go b/internal/analytics/client/sync/reporters/test.go index 7fe70846b6..f4fa7a23fe 100644 --- a/internal/analytics/client/sync/reporters/test.go +++ b/internal/analytics/client/sync/reporters/test.go @@ -5,14 +5,18 @@ import ( "path/filepath" "github.com/ActiveState/cli/internal/analytics/dimensions" + "github.com/ActiveState/cli/internal/config" + "github.com/ActiveState/cli/internal/constants" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/fileutils" "github.com/ActiveState/cli/internal/installation/storage" "github.com/ActiveState/cli/internal/logging" + configMediator "github.com/ActiveState/cli/internal/mediators/config" ) type TestReporter struct { path string + cfg *config.Instance } const TestReportFilename = "analytics.log" @@ -23,8 +27,12 @@ func TestReportFilepath() string { return filepath.Join(appdata, TestReportFilename) } -func NewTestReporter(path string) *TestReporter { - return &TestReporter{path} +func NewTestReporter(path string, cfg *config.Instance) *TestReporter { + reporter := &TestReporter{path, cfg} + configMediator.AddListener(constants.AnalyticsPixelOverrideConfig, func() { + reporter.cfg = cfg + }) + return reporter } func (r *TestReporter) ID() string { @@ -36,11 +44,13 @@ type TestLogEntry struct { Action string Source string Label string + URL string Dimensions *dimensions.Values } func (r *TestReporter) Event(category, action, source, label string, d *dimensions.Values) error { - b, err := json.Marshal(TestLogEntry{category, action, source, label, d}) + url := r.cfg.GetString(constants.AnalyticsPixelOverrideConfig) + b, err := json.Marshal(TestLogEntry{category, action, source, label, url, d}) if err != nil { return errs.Wrap(err, "Could not marshal test log entry") } diff --git a/internal/constants/constants.go b/internal/constants/constants.go index fdbf845602..9988923a93 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -412,6 +412,9 @@ const SecurityPromptConfig = "security.prompt.enabled" // SecurityPromptLevelConfig is the config key used to determine the level of security prompts const SecurityPromptLevelConfig = "security.prompt.level" +// AnalyticsPixelOverrideConfig is the config key used to override the analytics pixel url +const AnalyticsPixelOverrideConfig = "report.analytics.endpoint" + // UpdateEndpointConfig is the config key used to determine the update endpoint to use const UpdateEndpointConfig = "update.endpoint" diff --git a/test/integration/analytics_int_test.go b/test/integration/analytics_int_test.go index 37f9c38ac3..6fac31f40b 100644 --- a/test/integration/analytics_int_test.go +++ b/test/integration/analytics_int_test.go @@ -12,9 +12,14 @@ import ( "github.com/ActiveState/termtest" "github.com/thoas/go-funk" + "golang.org/x/net/context" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/ipc" "github.com/ActiveState/cli/internal/runbits/runtime/trigger" + "github.com/ActiveState/cli/internal/svcctl" "github.com/ActiveState/cli/internal/testhelpers/suite" + "github.com/ActiveState/cli/pkg/platform/model" "github.com/ActiveState/cli/internal/analytics/client/sync/reporters" anaConst "github.com/ActiveState/cli/internal/analytics/constants" @@ -644,6 +649,79 @@ func (suite *AnalyticsIntegrationTestSuite) TestCIAndInteractiveDimensions() { } } +func (suite *AnalyticsIntegrationTestSuite) TestAnalyticsPixelOverride() { + suite.OnlyRunForTags(tagsuite.Analytics) + + ts := e2e.New(suite.T(), false) + defer ts.Close() + + testURL := "https://example.com" + cp := ts.Spawn("config", "set", constants.AnalyticsPixelOverrideConfig, testURL) + cp.Expect("Successfully set config key") + cp.ExpectExitCode(0) + + // Create IPC client using the test's socket directory to connect to the same state-svc instance + // that the spawned state tool commands are using. + // We make a request to the service directly to ensure we're hitting an endpoint that will send an event. + sockPath := &ipc.SockPath{ + RootDir: ts.Dirs.SockRoot, + AppName: constants.CommandName, + AppChannel: constants.ChannelName, + } + ipcClient := ipc.NewClient(sockPath) + + svcPort, err := svcctl.LocateHTTP(ipcClient) + suite.Require().NoError(err, errs.JoinMessage(err)) + + svcmodel := model.NewSvcModel(svcPort) + _, err = svcmodel.LocalProjects(context.Background()) + suite.Require().NoError(err, errs.JoinMessage(err)) + + time.Sleep(time.Second) // Ensure state-svc has time to report events + + suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename) + events := parseAnalyticsEvents(suite, ts) + suite.Require().NotEmpty(events) + + // Some events will fire before the config is updated, so we expect to + // find at least one event with the new configuration values after the service is restarted. + found := false + for _, e := range events { + // Specifically check an event sent via the state-svc and ensure that the URL is the one we set in the config + if e.Category == anaConst.CatStateSvc && e.Action == "endpoint" && e.Label == "Projects" { + suite.Assert().Equal(testURL, e.URL) + found = true + } + } + suite.Assert().True(found, "Should find at least one state-svc endpoint Projects event") + + // Check that all events after the config set event have the correct URL. + configSetEventIndex := -1 + for i, e := range events { + if e.Category == anaConst.CatConfig && e.Action == anaConst.ActConfigSet && e.Label == constants.AnalyticsPixelOverrideConfig { + configSetEventIndex = i + break + } + } + suite.Require().NotEqual(-1, configSetEventIndex, "Should find the config set event") + + // Check all events after config set, skipping the immediate update event + for i, e := range events { + if i > configSetEventIndex { + // Skip the immediate update event right after config set. + // The update event is sent before the config is updated, so it will have an empty URL. + if i == configSetEventIndex+1 && e.Category == "updates" { + continue + } + // All other events with URLs should have the correct one. + // This includes events from the state tool and the state-svc. + if e.URL != testURL { + suite.Equal(testURL, e.URL, "Event after config set (%s:%s) should have the updated URL", e.Category, e.Action) + } + } + } +} + func TestAnalyticsIntegrationTestSuite(t *testing.T) { suite.Run(t, new(AnalyticsIntegrationTestSuite)) }