github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/test/integration/analytics_int_test.go (about)

     1  package integration
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"path/filepath"
     7  	"runtime"
     8  	"sort"
     9  	"strings"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/ActiveState/cli/internal/testhelpers/suite"
    14  	"github.com/ActiveState/termtest"
    15  	"github.com/thoas/go-funk"
    16  
    17  	"github.com/ActiveState/cli/internal/analytics/client/sync/reporters"
    18  	anaConst "github.com/ActiveState/cli/internal/analytics/constants"
    19  	"github.com/ActiveState/cli/internal/condition"
    20  	"github.com/ActiveState/cli/internal/constants"
    21  	"github.com/ActiveState/cli/internal/fileutils"
    22  	"github.com/ActiveState/cli/internal/rtutils"
    23  	"github.com/ActiveState/cli/internal/testhelpers/e2e"
    24  	helperSuite "github.com/ActiveState/cli/internal/testhelpers/suite"
    25  	"github.com/ActiveState/cli/internal/testhelpers/tagsuite"
    26  	"github.com/ActiveState/cli/pkg/platform/runtime/target"
    27  )
    28  
    29  type AnalyticsIntegrationTestSuite struct {
    30  	tagsuite.Suite
    31  	eventsfile string
    32  }
    33  
    34  // TestHeartbeats ensures that heartbeats are send on runtime use
    35  // Note the heartbeat code especially is a little awkward as we have to account for timing offsets between state and
    36  // state-svc. For that reason we tend to assert "greater than" rather than equals, because checking for equals introduces
    37  // race conditions into the testing suite (not the state tool itself).
    38  func (suite *AnalyticsIntegrationTestSuite) TestHeartbeats() {
    39  	suite.OnlyRunForTags(tagsuite.Analytics, tagsuite.Critical)
    40  
    41  	/* TEST SETUP */
    42  
    43  	ts := e2e.New(suite.T(), true)
    44  	defer ts.Close()
    45  
    46  	namespace := "ActiveState-CLI/Alternate-Python"
    47  	commitID := "efcc851f-1451-4d0a-9dcb-074ac3f35f0a"
    48  
    49  	// We want to do a clean test without an activate event, so we have to manually seed the yaml
    50  	ts.PrepareProject(namespace, commitID)
    51  
    52  	heartbeatInterval := 1000 // in milliseconds
    53  	sleepTime := time.Duration(heartbeatInterval) * time.Millisecond
    54  	sleepTime = sleepTime + (sleepTime / 2)
    55  
    56  	env := []string{
    57  		constants.DisableRuntime + "=false",
    58  		fmt.Sprintf("%s=%d", constants.HeartbeatIntervalEnvVarName, heartbeatInterval),
    59  	}
    60  
    61  	/* ACTIVATE TESTS */
    62  
    63  	// Produce Activate Heartbeats
    64  
    65  	var cp *e2e.SpawnedCmd
    66  	if runtime.GOOS == "windows" {
    67  		cp = ts.SpawnCmdWithOpts("cmd.exe",
    68  			e2e.OptArgs("/k", "state", "activate"),
    69  			e2e.OptWD(ts.Dirs.Work),
    70  			e2e.OptAppendEnv(env...),
    71  		)
    72  	} else {
    73  		cp = ts.SpawnWithOpts(e2e.OptArgs("activate"),
    74  			e2e.OptWD(ts.Dirs.Work),
    75  			e2e.OptAppendEnv(env...),
    76  		)
    77  	}
    78  
    79  	cp.Expect("Creating a Virtual Environment")
    80  	cp.Expect("Activated", e2e.RuntimeSourcingTimeoutOpt)
    81  	cp.ExpectInput(termtest.OptExpectTimeout(120 * time.Second))
    82  
    83  	time.Sleep(time.Second) // Ensure state-svc has time to report events
    84  
    85  	// By this point the activate heartbeats should have been recorded
    86  
    87  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
    88  
    89  	events := parseAnalyticsEvents(suite, ts)
    90  
    91  	// Now it's time for us to assert that we are seeing the expected number of events
    92  
    93  	suite.Require().NotEmpty(events)
    94  
    95  	// Runtime:start events
    96  	suite.assertNEvents(events, 1, anaConst.CatRuntimeDebug, anaConst.ActRuntimeStart, anaConst.SrcStateTool,
    97  		fmt.Sprintf("output:\n%s\n%s",
    98  			cp.Output(), ts.DebugLogsDump()))
    99  
   100  	// Runtime:success events
   101  	suite.assertNEvents(events, 1, anaConst.CatRuntimeDebug, anaConst.ActRuntimeSuccess, anaConst.SrcStateTool,
   102  		fmt.Sprintf("output:\n%s\n%s",
   103  			cp.Output(), ts.DebugLogsDump()))
   104  
   105  	// Runtime-use:attempts events
   106  	attemptInitialCount := countEvents(events, anaConst.CatRuntimeUsage, anaConst.ActRuntimeAttempt, anaConst.SrcStateTool)
   107  	suite.Equal(1, attemptInitialCount, "Activate should have resulted in 1 attempt")
   108  
   109  	// Runtime-use:heartbeat events
   110  	heartbeatInitialCount := countEvents(events, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcStateTool)
   111  	if heartbeatInitialCount < 2 {
   112  		// It's possible due to the timing of the heartbeats and the fact that they are async that we have gotten either
   113  		// one or two by this point. Technically more is possible, just very unlikely.
   114  		suite.Fail(fmt.Sprintf("Received %d heartbeats, realistically we should at least have gotten 2", heartbeatInitialCount))
   115  	}
   116  
   117  	// Wait for additional heartbeats to be reported, because our activated shell is still open
   118  
   119  	time.Sleep(sleepTime)
   120  
   121  	events = parseAnalyticsEvents(suite, ts)
   122  	suite.Require().NotEmpty(events)
   123  
   124  	suite.assertNEvents(events, 1, anaConst.CatRuntimeUsage, anaConst.ActRuntimeAttempt, anaConst.SrcStateTool, "Should still only have 1 attempt")
   125  
   126  	// Runtime-use:heartbeat events - should now be at least +1 because we waited <heartbeatInterval>
   127  	suite.assertGtEvents(events, heartbeatInitialCount, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcStateTool,
   128  		fmt.Sprintf("output:\n%s\n%s",
   129  			cp.Output(), ts.DebugLogsDump()))
   130  
   131  	/* EXECUTOR TESTS */
   132  
   133  	// Test that executor is sending heartbeats
   134  	suite.Run("Executors", func() {
   135  		cp.SendLine("python3 -c \"import sys; print(sys.copyright)\"")
   136  		cp.Expect("provided by ActiveState")
   137  
   138  		time.Sleep(sleepTime)
   139  
   140  		eventsAfterExecutor := parseAnalyticsEvents(suite, ts)
   141  		suite.Require().Greater(len(eventsAfterExecutor), len(events), "Should have received more events after running executor")
   142  
   143  		executorEvents := filterEvents(eventsAfterExecutor, func(e reporters.TestLogEntry) bool {
   144  			if e.Dimensions == nil || e.Dimensions.Trigger == nil {
   145  				return false
   146  			}
   147  			return (*e.Dimensions.Trigger) == target.TriggerExecutor.String()
   148  		})
   149  		suite.Require().Equal(1, countEvents(executorEvents, anaConst.CatRuntimeUsage, anaConst.ActRuntimeAttempt, anaConst.SrcExecutor),
   150  			ts.DebugMessage("Should have a runtime attempt, events:\n"+suite.summarizeEvents(executorEvents)))
   151  		suite.Require().Equal(1, countEvents(eventsAfterExecutor, anaConst.CatDebug, anaConst.ActExecutorExit, anaConst.SrcExecutor),
   152  			ts.DebugMessage("Should have an executor exit event, events:\n"+suite.summarizeEvents(executorEvents)))
   153  
   154  		// It's possible due to the timing of the heartbeats and the fact that they are async that we have gotten either
   155  		// one or two by this point. Technically more is possible, just very unlikely.
   156  		numHeartbeats := countEvents(executorEvents, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcExecutor)
   157  		suite.Require().Greater(numHeartbeats, 0, "Should have a heartbeat")
   158  		suite.Require().LessOrEqual(numHeartbeats, 2, "Should not have excessive heartbeats")
   159  		var heartbeatEvent *reporters.TestLogEntry
   160  		for _, e := range executorEvents {
   161  			if e.Action == anaConst.ActRuntimeHeartbeat {
   162  				heartbeatEvent = &e
   163  			}
   164  		}
   165  		suite.Require().NotNil(heartbeatEvent, "Should have a heartbeat event")
   166  		suite.Require().Equal(*heartbeatEvent.Dimensions.ProjectNameSpace, namespace)
   167  		suite.Require().Equal(*heartbeatEvent.Dimensions.CommitID, commitID)
   168  	})
   169  
   170  	/* ACTIVATE SHUTDOWN TESTS */
   171  
   172  	cp.SendLine("exit")
   173  	if runtime.GOOS == "windows" {
   174  		// We have to exit twice on windows, as we're running through `cmd /k`
   175  		cp.SendLine("exit")
   176  	}
   177  	suite.Require().NoError(rtutils.Timeout(func() error {
   178  		return cp.ExpectExitCode(0)
   179  	}, 5*time.Second), ts.DebugMessage("Timed out waiting for exit code"))
   180  
   181  	time.Sleep(sleepTime) // give time to let rtwatcher detect process has exited
   182  
   183  	// Test that we are no longer sending heartbeats
   184  
   185  	events = parseAnalyticsEvents(suite, ts)
   186  	suite.Require().NotEmpty(events)
   187  	eventsAfterExit := countEvents(events, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcExecutor)
   188  
   189  	time.Sleep(sleepTime)
   190  
   191  	eventsAfter := parseAnalyticsEvents(suite, ts)
   192  	suite.Require().NotEmpty(eventsAfter)
   193  	eventsAfterExitAndWait := countEvents(eventsAfter, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcExecutor)
   194  
   195  	suite.Equal(eventsAfterExit, eventsAfterExitAndWait,
   196  		fmt.Sprintf("Heartbeats should stop ticking after exiting subshell.\n"+
   197  			"Unexpected events: %s", suite.summarizeEvents(filterHeartbeats(eventsAfter[len(events):])),
   198  		))
   199  
   200  	// Ensure any analytics events from the state tool have the instance ID set
   201  	for _, e := range events {
   202  		if strings.Contains(e.Category, "state-svc") || strings.Contains(e.Action, "state-svc") {
   203  			continue
   204  		}
   205  		suite.NotEmpty(e.Dimensions.InstanceID)
   206  	}
   207  
   208  	suite.assertSequentialEvents(events)
   209  }
   210  
   211  func (suite *AnalyticsIntegrationTestSuite) TestExecEvents() {
   212  	suite.OnlyRunForTags(tagsuite.Analytics, tagsuite.Critical)
   213  
   214  	/* TEST SETUP */
   215  
   216  	ts := e2e.New(suite.T(), true)
   217  	defer ts.Close()
   218  
   219  	namespace := "ActiveState-CLI/Alternate-Python"
   220  	commitID := "efcc851f-1451-4d0a-9dcb-074ac3f35f0a"
   221  
   222  	// We want to do a clean test without an activate event, so we have to manually seed the yaml
   223  	ts.PrepareProject(namespace, commitID)
   224  
   225  	heartbeatInterval := 1000 // in milliseconds
   226  	sleepTime := time.Duration(heartbeatInterval) * time.Millisecond
   227  	sleepTime = sleepTime + (sleepTime / 2)
   228  
   229  	env := []string{
   230  		constants.DisableRuntime + "=false",
   231  		fmt.Sprintf("%s=%d", constants.HeartbeatIntervalEnvVarName, heartbeatInterval),
   232  	}
   233  
   234  	/* EXEC TESTS */
   235  
   236  	cp := ts.SpawnWithOpts(
   237  		e2e.OptArgs("exec", "--", "python3", "-c", fmt.Sprintf("import time; time.sleep(%f); print('DONE')", sleepTime.Seconds())),
   238  		e2e.OptWD(ts.Dirs.Work),
   239  		e2e.OptAppendEnv(env...),
   240  	)
   241  
   242  	cp.Expect("DONE", e2e.RuntimeSourcingTimeoutOpt)
   243  
   244  	time.Sleep(sleepTime)
   245  
   246  	events := parseAnalyticsEvents(suite, ts)
   247  	suite.Require().NotEmpty(events)
   248  
   249  	runtimeEvents := filterEvents(events, func(e reporters.TestLogEntry) bool {
   250  		return e.Category == anaConst.CatRuntimeUsage
   251  	})
   252  
   253  	suite.Equal(1, countEvents(events, anaConst.CatRuntimeUsage, anaConst.ActRuntimeAttempt, anaConst.SrcStateTool),
   254  		ts.DebugMessage("Should have a runtime attempt, events:\n"+suite.summarizeEvents(runtimeEvents)))
   255  
   256  	suite.assertGtEvents(events, 0, anaConst.CatRuntimeUsage, anaConst.ActRuntimeHeartbeat, anaConst.SrcStateTool,
   257  		"Expected new heartbeats after state exec")
   258  
   259  	cp.ExpectExitCode(0)
   260  }
   261  
   262  func countEvents(events []reporters.TestLogEntry, category, action, source string) int {
   263  	filteredEvents := funk.Filter(events, func(e reporters.TestLogEntry) bool {
   264  		return e.Category == category && e.Action == action && e.Source == source
   265  	}).([]reporters.TestLogEntry)
   266  	return len(filteredEvents)
   267  }
   268  
   269  func filterHeartbeats(events []reporters.TestLogEntry) []reporters.TestLogEntry {
   270  	return filterEvents(events, func(e reporters.TestLogEntry) bool {
   271  		return e.Category == anaConst.CatRuntimeUsage && e.Action == anaConst.ActRuntimeHeartbeat
   272  	})
   273  }
   274  
   275  func filterEvents(events []reporters.TestLogEntry, filters ...func(e reporters.TestLogEntry) bool) []reporters.TestLogEntry {
   276  	filteredEvents := funk.Filter(events, func(e reporters.TestLogEntry) bool {
   277  		for _, filter := range filters {
   278  			if !filter(e) {
   279  				return false
   280  			}
   281  		}
   282  		return true
   283  	}).([]reporters.TestLogEntry)
   284  	return filteredEvents
   285  }
   286  
   287  func (suite *AnalyticsIntegrationTestSuite) assertNEvents(events []reporters.TestLogEntry,
   288  	expectedN int, category, action, source string, errMsg string) {
   289  	suite.Assert().Equal(expectedN, countEvents(events, category, action, source),
   290  		"Expected %d %s:%s events.\nFile location: %s\nEvents received:\n%s\nError:\n%s",
   291  		expectedN, category, action, suite.eventsfile, suite.summarizeEvents(events), errMsg)
   292  }
   293  
   294  func (suite *AnalyticsIntegrationTestSuite) assertGtEvents(events []reporters.TestLogEntry,
   295  	greaterThanN int, category, action, source string, errMsg string) {
   296  	suite.Assert().Greater(countEvents(events, category, action, source), greaterThanN,
   297  		fmt.Sprintf("Expected more than %d %s:%s events.\nFile location: %s\nEvents received:\n%s\nError:\n%s",
   298  			greaterThanN, category, action, suite.eventsfile, suite.summarizeEvents(events), errMsg))
   299  }
   300  
   301  func (suite *AnalyticsIntegrationTestSuite) assertSequentialEvents(events []reporters.TestLogEntry) {
   302  	seq := map[string]int{}
   303  
   304  	// Since sequence is established client-side and then reported async it's possible that the sequence does not match the
   305  	// slice ordering of events
   306  	sort.Slice(events, func(i, j int) bool {
   307  		return *events[i].Dimensions.Sequence < *events[j].Dimensions.Sequence
   308  	})
   309  
   310  	var lastEvent reporters.TestLogEntry
   311  	for _, ev := range events {
   312  		if *ev.Dimensions.Sequence == -1 {
   313  			continue // The sequence of this event is irrelevant
   314  		}
   315  		// Sequence is per instance ID
   316  		key := (*ev.Dimensions.InstanceID)[0:6]
   317  		if v, ok := seq[key]; ok {
   318  			if (v + 1) != *ev.Dimensions.Sequence {
   319  				suite.Failf(fmt.Sprintf("Events are not sequential, expected %d but got %d", v+1, *ev.Dimensions.Sequence),
   320  					suite.summarizeEventSequence([]reporters.TestLogEntry{
   321  						lastEvent, ev,
   322  					}))
   323  			}
   324  		} else {
   325  			if *ev.Dimensions.Sequence != 0 {
   326  				suite.Fail(fmt.Sprintf("Sequence should start at 0, got: %v\nevents:\n %v",
   327  					suite.summarizeEventSequence([]reporters.TestLogEntry{ev}),
   328  					suite.summarizeEventSequence(events)))
   329  			}
   330  		}
   331  		seq[key] = *ev.Dimensions.Sequence
   332  		lastEvent = ev
   333  	}
   334  }
   335  
   336  func (suite *AnalyticsIntegrationTestSuite) summarizeEvents(events []reporters.TestLogEntry) string {
   337  	summary := []string{}
   338  	for _, event := range events {
   339  		summary = append(summary, fmt.Sprintf("%s:%s:%s (%s)", event.Category, event.Action, event.Label, event.Source))
   340  	}
   341  	return strings.Join(summary, "\n")
   342  }
   343  
   344  func (suite *AnalyticsIntegrationTestSuite) summarizeEventSequence(events []reporters.TestLogEntry) string {
   345  	summary := []string{}
   346  	for _, event := range events {
   347  		summary = append(summary, fmt.Sprintf("%s:%s:%s (%s seq: %s:%s:%d)\n",
   348  			event.Category, event.Action, event.Label, event.Source,
   349  			*event.Dimensions.Command, (*event.Dimensions.InstanceID)[0:6], *event.Dimensions.Sequence))
   350  	}
   351  	return strings.Join(summary, "\n")
   352  }
   353  
   354  type TestingSuiteForAnalytics interface {
   355  	Require() *helperSuite.Assertions
   356  }
   357  
   358  func parseAnalyticsEvents(suite TestingSuiteForAnalytics, ts *e2e.Session) []reporters.TestLogEntry {
   359  	time.Sleep(time.Second) // give svc time to process events
   360  
   361  	file := filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   362  	suite.Require().FileExists(file, ts.DebugMessage(""))
   363  
   364  	b, err := fileutils.ReadFile(file)
   365  	suite.Require().NoError(err)
   366  
   367  	var result []reporters.TestLogEntry
   368  	entries := strings.Split(string(b), "\x00")
   369  	for _, entry := range entries {
   370  		if len(entry) == 0 {
   371  			continue
   372  		}
   373  
   374  		var parsedEntry reporters.TestLogEntry
   375  		err := json.Unmarshal([]byte(entry), &parsedEntry)
   376  		suite.Require().NoError(err, fmt.Sprintf("path: %s, value: \n%s\n", file, entry))
   377  		result = append(result, parsedEntry)
   378  	}
   379  
   380  	return result
   381  }
   382  
   383  func (suite *AnalyticsIntegrationTestSuite) TestShim() {
   384  	suite.OnlyRunForTags(tagsuite.Analytics)
   385  
   386  	ts := e2e.New(suite.T(), true)
   387  	defer ts.Close()
   388  
   389  	asyData := strings.TrimSpace(`
   390  project: https://platform.activestate.com/ActiveState-CLI/test
   391  scripts:
   392    - name: pip
   393      language: bash
   394      standalone: true
   395      value: echo "pip"
   396  `)
   397  
   398  	ts.PrepareActiveStateYAML(asyData)
   399  	ts.PrepareCommitIdFile("9090c128-e948-4388-8f7f-96e2c1e00d98")
   400  
   401  	cp := ts.SpawnWithOpts(
   402  		e2e.OptArgs("activate", "ActiveState-CLI/Alternate-Python"),
   403  		e2e.OptWD(ts.Dirs.Work),
   404  	)
   405  
   406  	cp.Expect("Creating a Virtual Environment")
   407  	cp.Expect("Skipping runtime setup")
   408  	cp.Expect("Activated")
   409  	cp.ExpectInput(termtest.OptExpectTimeout(10 * time.Second))
   410  
   411  	cp = ts.Spawn("run", "pip")
   412  	cp.Wait()
   413  
   414  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   415  	events := parseAnalyticsEvents(suite, ts)
   416  
   417  	var found int
   418  	for _, event := range events {
   419  		if event.Category == anaConst.CatRunCmd && event.Action == "run" {
   420  			found++
   421  			suite.Equal(constants.PipShim, event.Label)
   422  		}
   423  	}
   424  
   425  	if found <= 0 {
   426  		suite.Fail("Did not find shim event")
   427  	}
   428  }
   429  
   430  func (suite *AnalyticsIntegrationTestSuite) TestSend() {
   431  	suite.OnlyRunForTags(tagsuite.Analytics, tagsuite.Critical)
   432  
   433  	ts := e2e.New(suite.T(), true)
   434  	defer ts.Close()
   435  
   436  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   437  
   438  	cp := ts.Spawn("--version")
   439  	cp.Expect("Version")
   440  	cp.ExpectExitCode(0)
   441  
   442  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   443  
   444  	cp = ts.Spawn("config", "set", constants.ReportAnalyticsConfig, "false")
   445  	cp.Expect("Successfully")
   446  	cp.ExpectExitCode(0)
   447  
   448  	initialEvents := parseAnalyticsEvents(suite, ts)
   449  	suite.assertSequentialEvents(initialEvents)
   450  
   451  	cp = ts.Spawn("--version")
   452  	cp.Expect("Version")
   453  	cp.ExpectExitCode(0)
   454  
   455  	events := parseAnalyticsEvents(suite, ts)
   456  	currentEvents := len(events)
   457  	if currentEvents > len(initialEvents) {
   458  		suite.Failf("Should not get additional events", "Got %d additional events, should be 0", currentEvents-len(initialEvents))
   459  	}
   460  
   461  	suite.assertSequentialEvents(events)
   462  }
   463  
   464  func (suite *AnalyticsIntegrationTestSuite) TestSequenceAndFlags() {
   465  	suite.OnlyRunForTags(tagsuite.Analytics)
   466  
   467  	ts := e2e.New(suite.T(), true)
   468  	defer ts.Close()
   469  
   470  	cp := ts.Spawn("--version")
   471  	cp.Expect("Version")
   472  	cp.ExpectExitCode(0)
   473  
   474  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   475  	events := parseAnalyticsEvents(suite, ts)
   476  	suite.assertSequentialEvents(events)
   477  
   478  	found := false
   479  	for _, ev := range events {
   480  		if ev.Category == "run-command" && ev.Action == "" && ev.Label == "--version" {
   481  			found = true
   482  			break
   483  		}
   484  	}
   485  
   486  	suite.True(found, "Should have run-command event with flags, actual: %s", suite.summarizeEvents(events))
   487  }
   488  
   489  func (suite *AnalyticsIntegrationTestSuite) TestInputError() {
   490  	suite.OnlyRunForTags(tagsuite.Analytics)
   491  
   492  	ts := e2e.New(suite.T(), true)
   493  	defer ts.Close()
   494  
   495  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   496  
   497  	cp := ts.Spawn("clean", "uninstall", "badarg", "--mono")
   498  	cp.ExpectExitCode(1)
   499  	ts.IgnoreLogErrors()
   500  
   501  	events := parseAnalyticsEvents(suite, ts)
   502  	suite.assertSequentialEvents(events)
   503  
   504  	suite.assertNEvents(events, 1, anaConst.CatDebug, anaConst.ActCommandInputError, anaConst.SrcStateTool,
   505  		fmt.Sprintf("output:\n%s\n%s",
   506  			cp.Output(), ts.DebugLogsDump()))
   507  
   508  	for _, event := range events {
   509  		if event.Category == anaConst.CatDebug && event.Action == anaConst.ActCommandInputError {
   510  			suite.Equal("state clean uninstall --mono", event.Label)
   511  		}
   512  	}
   513  }
   514  
   515  func (suite *AnalyticsIntegrationTestSuite) TestAttempts() {
   516  	suite.OnlyRunForTags(tagsuite.Analytics)
   517  
   518  	ts := e2e.New(suite.T(), true)
   519  	defer ts.Close()
   520  
   521  	ts.PrepareProject("ActiveState-CLI/test", "9090c128-e948-4388-8f7f-96e2c1e00d98")
   522  
   523  	cp := ts.SpawnWithOpts(
   524  		e2e.OptArgs("activate", "ActiveState-CLI/Alternate-Python"),
   525  		e2e.OptAppendEnv(constants.DisableRuntime+"=false"),
   526  		e2e.OptAppendEnv(constants.DisableActivateEventsEnvVarName+"=false"),
   527  		e2e.OptWD(ts.Dirs.Work),
   528  	)
   529  
   530  	cp.Expect("Creating a Virtual Environment")
   531  	cp.Expect("Activated", e2e.RuntimeSourcingTimeoutOpt)
   532  	cp.ExpectInput(termtest.OptExpectTimeout(120 * time.Second))
   533  
   534  	cp.SendLine("python3 --version")
   535  	cp.Expect("Python 3.")
   536  
   537  	time.Sleep(time.Second) // Ensure state-svc has time to report events
   538  
   539  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   540  	events := parseAnalyticsEvents(suite, ts)
   541  
   542  	var foundAttempts int
   543  	var foundExecs int
   544  	for _, e := range events {
   545  		if strings.Contains(e.Category, "runtime") && strings.Contains(e.Action, "attempt") {
   546  			foundAttempts++
   547  			if strings.Contains(*e.Dimensions.Trigger, "exec") && strings.Contains(e.Source, anaConst.SrcExecutor) {
   548  				foundExecs++
   549  			}
   550  		}
   551  	}
   552  
   553  	if foundAttempts == 2 {
   554  		suite.Fail("Should find multiple runtime attempts")
   555  	}
   556  	if foundExecs == 1 {
   557  		suite.Fail("Should find one exec event")
   558  	}
   559  }
   560  
   561  func (suite *AnalyticsIntegrationTestSuite) TestHeapEvents() {
   562  	suite.OnlyRunForTags(tagsuite.Analytics)
   563  
   564  	ts := e2e.New(suite.T(), true)
   565  	defer ts.Close()
   566  
   567  	ts.LoginAsPersistentUser()
   568  
   569  	cp := ts.SpawnWithOpts(e2e.OptArgs("activate", "ActiveState-CLI/Alternate-Python"),
   570  		e2e.OptWD(ts.Dirs.Work),
   571  	)
   572  
   573  	cp.Expect("Creating a Virtual Environment")
   574  	cp.Expect("Activated", e2e.RuntimeSourcingTimeoutOpt)
   575  	cp.ExpectInput(termtest.OptExpectTimeout(120 * time.Second))
   576  
   577  	time.Sleep(time.Second) // Ensure state-svc has time to report events
   578  
   579  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   580  
   581  	events := parseAnalyticsEvents(suite, ts)
   582  	suite.Require().NotEmpty(events)
   583  
   584  	// Ensure analytics events have required/important fields
   585  	for _, e := range events {
   586  		// Skip events that are not relevant to Heap
   587  		// State Service, Update, and Auth events can run before a user has logged in
   588  		if strings.Contains(e.Category, "state-svc") || strings.Contains(e.Action, "state-svc") || strings.Contains(e.Action, "auth") || strings.Contains(e.Category, "update") {
   589  			continue
   590  		}
   591  
   592  		// UserID is used to identify the user
   593  		suite.NotEmpty(e.Dimensions.UserID, "Event should have a user ID")
   594  
   595  		// Category and Action are primary attributes reported to Heap and should be set
   596  		suite.NotEmpty(e.Category, "Event category should not be empty")
   597  		suite.NotEmpty(e.Action, "Event action should not be empty")
   598  	}
   599  
   600  	suite.assertSequentialEvents(events)
   601  }
   602  
   603  func (suite *AnalyticsIntegrationTestSuite) TestConfigEvents() {
   604  	suite.OnlyRunForTags(tagsuite.Analytics, tagsuite.Config)
   605  
   606  	ts := e2e.New(suite.T(), true)
   607  	defer ts.Close()
   608  
   609  	cp := ts.SpawnWithOpts(e2e.OptArgs("config", "set", "optin.unstable", "false"),
   610  		e2e.OptWD(ts.Dirs.Work),
   611  	)
   612  	cp.Expect("Successfully set config key")
   613  
   614  	time.Sleep(time.Second) // Ensure state-svc has time to report events
   615  
   616  	cp = ts.SpawnWithOpts(e2e.OptArgs("config", "set", "optin.unstable", "true"),
   617  		e2e.OptWD(ts.Dirs.Work),
   618  	)
   619  	cp.Expect("Successfully set config key")
   620  
   621  	time.Sleep(time.Second) // Ensure state-svc has time to report events
   622  
   623  	suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   624  
   625  	events := parseAnalyticsEvents(suite, ts)
   626  	suite.Require().NotEmpty(events)
   627  
   628  	// Ensure analytics events have required/important fields
   629  	var found int
   630  	for _, e := range events {
   631  		if !strings.Contains(e.Category, anaConst.CatConfig) {
   632  			continue
   633  		}
   634  
   635  		if e.Label != "optin.unstable" {
   636  			suite.Fail("Incorrect config event label")
   637  		}
   638  		found++
   639  	}
   640  
   641  	if found < 2 {
   642  		suite.Fail("Should find multiple config events")
   643  	}
   644  
   645  	suite.assertNEvents(events, 1, anaConst.CatConfig, anaConst.ActConfigSet, anaConst.SrcStateTool, "Should be at one config set event")
   646  	suite.assertNEvents(events, 1, anaConst.CatConfig, anaConst.ActConfigUnset, anaConst.SrcStateTool, "Should be at one config unset event")
   647  	suite.assertSequentialEvents(events)
   648  }
   649  
   650  func (suite *AnalyticsIntegrationTestSuite) TestCIAndInteractiveDimensions() {
   651  	suite.OnlyRunForTags(tagsuite.Analytics)
   652  
   653  	ts := e2e.New(suite.T(), true)
   654  	defer ts.Close()
   655  
   656  	for _, interactive := range []bool{true, false} {
   657  		suite.T().Run(fmt.Sprintf("interactive: %v", interactive), func(t *testing.T) {
   658  			args := []string{"--version"}
   659  			if !interactive {
   660  				args = append(args, "--non-interactive")
   661  			}
   662  			cp := ts.SpawnWithOpts(e2e.OptArgs(args...))
   663  			cp.Expect("ActiveState CLI")
   664  			cp.ExpectExitCode(0)
   665  
   666  			time.Sleep(time.Second) // Ensure state-svc has time to report events
   667  
   668  			suite.eventsfile = filepath.Join(ts.Dirs.Config, reporters.TestReportFilename)
   669  			events := parseAnalyticsEvents(suite, ts)
   670  			suite.Require().NotEmpty(events)
   671  			processedAnEvent := false
   672  			for _, e := range events {
   673  				if !strings.Contains(e.Category, anaConst.CatRunCmd) || e.Label == "" {
   674  					continue // only look at spawned run-command events
   675  				}
   676  				interactiveEvent := !strings.Contains(e.Label, "--non-interactive")
   677  				if interactive != interactiveEvent {
   678  					continue // ignore the other spawned command
   679  				}
   680  				suite.Equal(condition.OnCI(), *e.Dimensions.CI, "analytics should report being on CI")
   681  				suite.Equal(interactive, *e.Dimensions.Interactive, "analytics did not report the correct interactive value for %v", e)
   682  				suite.Equal(condition.OnCI(), // not InActiveStateCI() because if it's false, we forgot to set ACTIVESTATE_CI env var in GitHub Actions scripts
   683  					*e.Dimensions.ActiveStateCI, "analytics did not report being in ActiveState CI")
   684  				processedAnEvent = true
   685  			}
   686  			suite.True(processedAnEvent, "did not actually test CI and Interactive dimensions")
   687  			suite.assertSequentialEvents(events)
   688  		})
   689  	}
   690  }
   691  
   692  func TestAnalyticsIntegrationTestSuite(t *testing.T) {
   693  	suite.Run(t, new(AnalyticsIntegrationTestSuite))
   694  }