diff --git a/.golangci.yml b/.golangci.yml index 4375148d7..30b2b846e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,7 +6,6 @@ linters-settings: gofmt: simplify: true govet: - check-shadowing: false # set this to true from time to time to check for possible issues disable-all: true enable: - asmdecl # report mismatches between assembly files and Go declarations @@ -36,19 +35,10 @@ linters: disable-all: true enable: - gofmt # Checks whether code was gofmt-ed - - golint # Differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes + # - revive # Needs a review of rules https://mattermost.atlassian.net/browse/MM-58690 - gosimple # Linter for Go source code that specializes in simplifying a code - govet # Examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - ineffassign # Detects when assignments to existing variables are not used - unconvert # Removes unnecessary type conversions - unused # Checks Go code for unused constants, variables, functions and types - exportloopref # Checks for pointers to enclosing loop variables - -issues: - exclude-rules: - - linters: - # Used to avoid errors regarding naming conventions - # We can remove this section once we decide to fix them - - golint - text: "should be.*ID|CamelCase$|FileJSON`$|ConsoleJSON`$|APIURL`$|calling this Response$" - diff --git a/loadtest/control/simulcontroller/actions.go b/loadtest/control/simulcontroller/actions.go index 73f07624d..d1c4ce707 100644 --- a/loadtest/control/simulcontroller/actions.go +++ b/loadtest/control/simulcontroller/actions.go @@ -68,6 +68,8 @@ func (c *SimulController) disconnect() error { } func (c *SimulController) reload(full bool) control.UserActionResponse { + start := time.Now() + if full { if err := c.disconnect(); err != nil { return control.UserActionResponse{Err: control.NewUserError(err)} @@ -81,6 +83,14 @@ func (c *SimulController) reload(full bool) control.UserActionResponse { } } + defer func() { + elapsed := time.Since(start).Seconds() + err := c.user.ObserveClientMetric(model.ClientPageLoadDuration, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } + }() + // A full reload always calls GET /api/v4/users?page=0&per_page=100, // regardless of GraphQL enabled or not _, err := c.user.GetUsers(0, 100) @@ -140,11 +150,17 @@ func (c *SimulController) loginOrSignUp(u user.User) control.UserActionResponse } func (c *SimulController) login(u user.User) control.UserActionResponse { + start := time.Now() for { resp := control.Login(u) if resp.Err == nil { err := c.connect() if err == nil { + elapsed := time.Since(start).Seconds() + err := c.user.ObserveClientMetric(model.ClientTimeToFirstByte, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } return resp } c.status <- c.newErrorStatus(err) @@ -281,12 +297,20 @@ func loadTeam(u user.User, team *model.Team, gqlEnabled bool) control.UserAction } func (c *SimulController) switchTeam(u user.User) control.UserActionResponse { + start := time.Now() team, err := u.Store().RandomTeam(store.SelectMemberOf | store.SelectNotCurrent) if errors.Is(err, memstore.ErrTeamStoreEmpty) { return control.UserActionResponse{Info: "no other team to switch to"} } else if err != nil { return control.UserActionResponse{Err: control.NewUserError(err)} } + defer func() { + elapsed := time.Since(start).Seconds() + err := c.user.ObserveClientMetric(model.ClientTeamSwitchDuration, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } + }() c.status <- c.newInfoStatus(fmt.Sprintf("switched to team %s", team.Id)) @@ -435,6 +459,7 @@ func fetchPostsInfo(u user.User, postsIds []string) error { } func viewChannel(u user.User, channel *model.Channel) control.UserActionResponse { + start := time.Now() collapsedThreads, resp := control.CollapsedThreadsEnabled(u) if resp.Err != nil { return resp @@ -478,6 +503,13 @@ func viewChannel(u user.User, channel *model.Channel) control.UserActionResponse // frequencies are also calculated that way. // This is a good enough approximation. if rand.Float64() < 0.01 { + defer func() { + elapsed := time.Since(start).Seconds() + err := u.ObserveClientMetric(model.ClientRHSLoadDuration, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } + }() excludeFileCount = false } @@ -521,6 +553,7 @@ func viewChannel(u user.User, channel *model.Channel) control.UserActionResponse } func switchChannel(u user.User) control.UserActionResponse { + start := time.Now() team, err := u.Store().CurrentTeam() if err != nil { return control.UserActionResponse{Err: control.NewUserError(err)} @@ -536,6 +569,13 @@ func switchChannel(u user.User) control.UserActionResponse { if resp := viewChannel(u, &channel); resp.Err != nil { return control.UserActionResponse{Err: control.NewUserError(resp.Err)} } + defer func() { + elapsed := time.Since(start).Seconds() + err := u.ObserveClientMetric(model.ClientChannelSwitchDuration, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } + }() if err := u.SetCurrentChannel(&channel); err != nil { return control.UserActionResponse{Err: control.NewUserError(err)} @@ -1443,6 +1483,7 @@ func sendTypingEventIfEnabled(u user.User, channelId string) error { } func (c *SimulController) viewGlobalThreads(u user.User) control.UserActionResponse { + start := time.Now() collapsedThreads, resp := control.CollapsedThreadsEnabled(u) if resp.Err != nil || !collapsedThreads { return resp @@ -1476,6 +1517,14 @@ func (c *SimulController) viewGlobalThreads(u user.User) control.UserActionRespo } } + defer func() { + elapsed := time.Since(start).Seconds() + err := c.user.ObserveClientMetric(model.ClientGlobalThreadsLoadDuration, elapsed) + if err != nil { + mlog.Warn("Failed to store observation", mlog.Err(err)) + } + }() + oldestThreadId := threads[len(threads)-1].PostId // scrolling between 1 and 3 times numScrolls := rand.Intn(3) + 1 @@ -2125,3 +2174,12 @@ func (c *SimulController) generateUserReport(u user.User) control.UserActionResp return control.UserActionResponse{Info: fmt.Sprintf("generated user report for %d users", totalUsers)} } + +func submitPerformanceReport(u user.User) control.UserActionResponse { + err := u.SubmitPerformanceReport() + if err != nil { + return control.UserActionResponse{Err: control.NewUserError(err)} + } + + return control.UserActionResponse{Info: "submitted client performance report"} +} diff --git a/loadtest/control/simulcontroller/periodic.go b/loadtest/control/simulcontroller/periodic.go index 12aea5c2e..57f82f25f 100644 --- a/loadtest/control/simulcontroller/periodic.go +++ b/loadtest/control/simulcontroller/periodic.go @@ -10,19 +10,33 @@ import ( const ( getUsersStatusByIdsInterval = 60 * time.Second + submitClientMetricsInterval = 60 * time.Second ) func (c *SimulController) periodicActions(wg *sync.WaitGroup) { - defer wg.Done() + getUserStatusTicker := time.NewTicker(getUsersStatusByIdsInterval) + submitMetricsTicker := time.NewTicker(submitClientMetricsInterval) + + defer func() { + submitMetricsTicker.Stop() + getUserStatusTicker.Stop() + wg.Done() + }() + for { select { - case <-time.After(getUsersStatusByIdsInterval): + case <-getUserStatusTicker.C: if resp := c.getUsersStatuses(); resp.Err != nil { c.status <- c.newErrorStatus(resp.Err) } else { c.status <- c.newInfoStatus(resp.Info) } - // We can add more periodic actions here. + case <-submitMetricsTicker.C: + if resp := submitPerformanceReport(c.user); resp.Err != nil { + c.status <- c.newErrorStatus(resp.Err) + } else { + c.status <- c.newInfoStatus(resp.Info) + } case <-c.disconnectChan: return } diff --git a/loadtest/store/memstore/store.go b/loadtest/store/memstore/store.go index f114f7e28..a33f5b77e 100644 --- a/loadtest/store/memstore/store.go +++ b/loadtest/store/memstore/store.go @@ -50,6 +50,7 @@ type MemStore struct { sidebarCategories map[string]map[string]*model.SidebarCategoryWithChannels drafts map[string]map[string]*model.Draft featureFlags map[string]bool + report *model.PerformanceReport } // New returns a new instance of MemStore with the given config. @@ -123,6 +124,7 @@ func (s *MemStore) Clear() { s.threadsQueue.Reset() clear(s.sidebarCategories) s.sidebarCategories = map[string]map[string]*model.SidebarCategoryWithChannels{} + s.report = &model.PerformanceReport{} clear(s.drafts) s.drafts = map[string]map[string]*model.Draft{} } @@ -1186,6 +1188,61 @@ func (s *MemStore) PostsWithAckRequests() ([]string, error) { return ids, nil } +func (s *MemStore) SetPerformanceReport(report *model.PerformanceReport) { + s.lock.Lock() + defer s.lock.Unlock() + + s.report = report +} + +func (s *MemStore) PerformanceReport() (*model.PerformanceReport, error) { + s.lock.Lock() + defer s.lock.Unlock() + if s.report == nil { + return nil, nil + } + + report := &model.PerformanceReport{ + Version: s.report.Version, + ClientID: s.report.ClientID, + Start: s.report.Start, + End: s.report.End, + } + + if s.report.Labels != nil { + report.Labels = make(map[string]string) + } + for k, v := range s.report.Labels { + report.Labels[k] = v + } + + if s.report.Histograms != nil { + report.Histograms = make([]*model.MetricSample, len(s.report.Histograms)) + } + for i, h := range s.report.Histograms { + report.Histograms[i] = &model.MetricSample{ + Metric: h.Metric, + Value: h.Value, + Timestamp: h.Timestamp, + Labels: h.Labels, + } + } + + if s.report.Counters != nil { + report.Counters = make([]*model.MetricSample, len(s.report.Counters)) + } + for i, h := range s.report.Counters { + report.Counters[i] = &model.MetricSample{ + Metric: h.Metric, + Value: h.Value, + Timestamp: h.Timestamp, + Labels: h.Labels, + } + } + + return report, nil +} + // SetDraft stores the draft for the given teamId, and channelId or rootId. func (s *MemStore) SetDraft(teamId, id string, draft *model.Draft) error { s.lock.Lock() diff --git a/loadtest/store/store.go b/loadtest/store/store.go index 3f2a1b45a..1f6b6796e 100644 --- a/loadtest/store/store.go +++ b/loadtest/store/store.go @@ -146,6 +146,9 @@ type UserStore interface { // PostsWithAckRequests returns IDs of the posts that asked for acknowledgment. PostsWithAckRequests() ([]string, error) + + // PerformanceReport returns a copy of underlying performance report + PerformanceReport() (*model.PerformanceReport, error) } // MutableUserStore is a super-set of UserStore which, apart from providing @@ -260,4 +263,7 @@ type MutableUserStore interface { // SidebarCategories SetCategories(teamID string, sidebarCategories *model.OrderedSidebarCategories) error + + // ClientPerformance + SetPerformanceReport(report *model.PerformanceReport) } diff --git a/loadtest/user/user.go b/loadtest/user/user.go index 157a3cf30..2ab0019be 100644 --- a/loadtest/user/user.go +++ b/loadtest/user/user.go @@ -325,4 +325,8 @@ type User interface { // GraphQL GetInitialDataGQL() error GetChannelsAndChannelMembersGQL(teamID string, includeDeleted bool, channelsCursor, channelMembersCursor string) (string, string, error) + + // Client Metrics + ObserveClientMetric(t model.MetricType, v float64) error + SubmitPerformanceReport() error } diff --git a/loadtest/user/userentity/report.go b/loadtest/user/userentity/report.go new file mode 100644 index 000000000..5957e7edc --- /dev/null +++ b/loadtest/user/userentity/report.go @@ -0,0 +1,52 @@ +package userentity + +import ( + "context" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +func (ue *UserEntity) ObserveClientMetric(t model.MetricType, v float64) error { + report, err := ue.store.PerformanceReport() + if err != nil { + return err + } + defer ue.store.SetPerformanceReport(report) + + switch t { + case model.ClientTimeToFirstByte, model.ClientFirstContentfulPaint, model.ClientLargestContentfulPaint, + model.ClientInteractionToNextPaint, model.ClientCumulativeLayoutShift, model.ClientChannelSwitchDuration, + model.ClientTeamSwitchDuration, model.ClientRHSLoadDuration: + if report.Histograms == nil { + report.Histograms = make([]*model.MetricSample, 0) + } + + report.Histograms = append(report.Histograms, &model.MetricSample{ + Metric: t, + Value: v, + Timestamp: float64(time.Now().UnixMilli()) / 1000, + }) + default: + // server also ignores the unkown typed metrics + } + return nil +} + +func (ue *UserEntity) SubmitPerformanceReport() error { + report, err := ue.store.PerformanceReport() + if err != nil { + return err + } + report.End = float64(time.Now().UnixMilli()) / 1000 + + _, err = ue.client.SubmitClientMetrics(context.Background(), report) + if err != nil { + return err + } + ue.store.SetPerformanceReport(&model.PerformanceReport{ + Start: float64(time.Now().UnixMilli()) / 1000, + }) + + return nil +} diff --git a/loadtest/user/userentity/user.go b/loadtest/user/userentity/user.go index 9996a945b..992753432 100644 --- a/loadtest/user/userentity/user.go +++ b/loadtest/user/userentity/user.go @@ -148,6 +148,15 @@ func New(setup Setup, config Config) *UserEntity { if err != nil { return nil } + ue.store.SetPerformanceReport(&model.PerformanceReport{ + Version: "0.1.0", + Labels: map[string]string{ + "platform": "other", + "agent": "other", + }, + ClientID: model.NewId(), + Start: float64(time.Now().UnixMilli()) / 1000, + }) return &ue }