From 5590e1604a9fc780f1ec4b1da7040c87162895ff Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Thu, 9 May 2024 20:49:02 +0200 Subject: [PATCH] Add new Metrics API (#26919) --- server/channels/api4/api.go | 1 + server/channels/api4/apitestlib.go | 19 +++ server/channels/api4/metrics.go | 38 +++++ server/channels/api4/metrics_test.go | 158 ++++++++++++++++++ server/channels/app/app_iface.go | 1 + server/channels/app/metrics.go | 51 ++++++ .../app/opentracing/opentracing_layer.go | 22 +++ server/einterfaces/metrics.go | 10 ++ server/einterfaces/mocks/MetricsInterface.go | 45 +++++ server/enterprise/metrics/metrics.go | 146 ++++++++++++++++ server/public/model/client4.go | 17 ++ server/public/model/metrics.go | 114 +++++++++++++ server/public/model/metrics_test.go | 78 +++++++++ server/public/model/utils.go | 13 ++ server/scripts/vet-api-check.sh | 1 + 15 files changed, 714 insertions(+) create mode 100644 server/channels/api4/metrics.go create mode 100644 server/channels/api4/metrics_test.go create mode 100644 server/channels/app/metrics.go create mode 100644 server/public/model/metrics.go create mode 100644 server/public/model/metrics_test.go diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 34270df84a..da735eaced 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -331,6 +331,7 @@ func Init(srv *app.Server) (*API, error) { api.InitReports() api.InitLimits() api.InitOutgoingOAuthConnection() + api.InitClientPerformanceMetrics() srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 66bc95b4ad..72df1aaf73 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -359,6 +359,25 @@ func SetupWithServerOptions(tb testing.TB, options []app.Option) *TestHelper { return th } +func SetupEnterpriseWithServerOptions(tb testing.TB, options []app.Option) *TestHelper { + if testing.Short() { + tb.SkipNow() + } + + if mainHelper == nil { + tb.SkipNow() + } + + dbStore := mainHelper.GetStore() + dbStore.DropAllTables() + dbStore.MarkSystemRanUnitTests() + mainHelper.PreloadMigrations() + searchEngine := mainHelper.GetSearchEngine() + th := setupTestHelper(dbStore, searchEngine, true, true, nil, options) + th.InitLogin() + return th +} + func (th *TestHelper) ShutdownApp() { done := make(chan bool) go func() { diff --git a/server/channels/api4/metrics.go b/server/channels/api4/metrics.go new file mode 100644 index 0000000000..4b82080117 --- /dev/null +++ b/server/channels/api4/metrics.go @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" +) + +func (api *API) InitClientPerformanceMetrics() { + api.BaseRoutes.APIRoot.Handle("/perf", api.APISessionRequired(submitPerformanceReport)).Methods("POST") +} + +func submitPerformanceReport(c *Context, w http.ResponseWriter, r *http.Request) { + // we return early if server does not have any metrics infra available + if c.App.Metrics() == nil || !*c.App.Config().MetricsSettings.EnableClientMetrics { + return + } + + var report model.PerformanceReport + if jsonErr := json.NewDecoder(r.Body).Decode(&report); jsonErr != nil { + c.SetInvalidParamWithErr("submitPerformanceReport", jsonErr) + return + } + + if err := report.IsValid(); err != nil { + c.SetInvalidParamWithErr("submitPerformanceReport", err) + return + } + + if appErr := c.App.RegisterPerformanceReport(c.AppContext, &report); appErr != nil { + c.Err = appErr + return + } +} diff --git a/server/channels/api4/metrics_test.go b/server/channels/api4/metrics_test.go new file mode 100644 index 0000000000..3fa992ac2d --- /dev/null +++ b/server/channels/api4/metrics_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "net/http" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" + "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/channels/app/platform" + "github.com/mattermost/mattermost/server/v8/einterfaces" + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" + "github.com/stretchr/testify/require" +) + +func setupMetricsMock() *mocks.MetricsInterface { + metricsMock := &mocks.MetricsInterface{} + metricsMock.On("IncrementWebsocketEvent", mock.AnythingOfType("model.WebsocketEventType")).Return() + metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("IncrementMemCacheInvalidationCounter", mock.AnythingOfType("string")).Return() + metricsMock.On("IncrementMemCacheMissCounter", mock.AnythingOfType("string")).Return() + metricsMock.On("IncrementMemCacheHitCounter", mock.AnythingOfType("string")).Return() + metricsMock.On("GetLoggerMetricsCollector").Return(nil) + metricsMock.On("IncrementMemCacheHitCounterSession").Return() + metricsMock.On("IncrementHTTPError").Return() + metricsMock.On("IncrementHTTPRequest").Return() + metricsMock.On("ObserveAPIEndpointDuration", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("Register").Return() + + return metricsMock +} +func TestSubmitMetrics(t *testing.T) { + t.Run("unauthenticated user should not submit metrics", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + _, err := th.Client.Logout(th.Context.Context()) + require.NoError(t, err) + + resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), nil) + + require.Error(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + // if the metrics is not enabled on server, we don't want to return + // an error code. + t.Run("metrics not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), nil) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("metrics enabled but invalid version", func(t *testing.T) { + metricsMock := setupMetricsMock() + + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return metricsMock + }) + t.Cleanup(func() { + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return nil + }) + }) + + th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics}) + defer th.TearDown() + + // enable metrics and add the license + th.App.Srv().SetLicense(model.NewTestLicense()) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" }) + + resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{ + Version: "0.1", + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("metrics enabled and valid", func(t *testing.T) { + metricsMock := setupMetricsMock() + metricsMock.On("IncrementClientLongTasks", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return metricsMock + }) + t.Cleanup(func() { + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return nil + }) + }) + + th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics}) + defer th.TearDown() + + // enable metrics and add the license + th.App.Srv().SetLicense(model.NewTestLicense()) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" }) + + resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{ + Version: "0.1", + Start: time.Now().Add(-1 * time.Minute).UnixMilli(), + End: time.Now().UnixMilli(), + Counters: []*model.MetricSample{ + {Metric: model.ClientLongTasks, Value: 1}, + }, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("metrics enabled but client metrics are disabled", func(t *testing.T) { + metricsMock := setupMetricsMock() + + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return metricsMock + }) + t.Cleanup(func() { + platform.RegisterMetricsInterface(func(_ *platform.PlatformService, _, _ string) einterfaces.MetricsInterface { + return nil + }) + }) + + th := SetupEnterpriseWithServerOptions(t, []app.Option{app.StartMetrics}) + defer th.TearDown() + + // enable metrics and add the license + th.App.Srv().SetLicense(model.NewTestLicense()) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.Enable = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.EnableClientMetrics = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.MetricsSettings.ListenAddress = ":0" }) + + resp, err := th.Client.SubmitClientMetrics(th.Context.Context(), &model.PerformanceReport{ + Version: "0.1", + Start: time.Now().Add(-1 * time.Minute).UnixMilli(), + End: time.Now().UnixMilli(), + Counters: []*model.MetricSample{ + {Metric: model.ClientLongTasks, Value: 1}, + }, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index ecb2849387..ed0ff7921b 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -1004,6 +1004,7 @@ type AppIface interface { RegenOutgoingWebhookToken(hook *model.OutgoingWebhook) (*model.OutgoingWebhook, *model.AppError) RegenerateOAuthAppSecret(app *model.OAuthApp) (*model.OAuthApp, *model.AppError) RegenerateTeamInviteId(teamID string) (*model.Team, *model.AppError) + RegisterPerformanceReport(rctx request.CTX, report *model.PerformanceReport) *model.AppError RegisterPluginCommand(pluginID string, command *model.Command) error RegisterPluginForSharedChannels(rctx request.CTX, opts model.RegisterPluginOpts) (remoteID string, err error) ReloadConfig() error diff --git a/server/channels/app/metrics.go b/server/channels/app/metrics.go new file mode 100644 index 0000000000..a0838ac17e --- /dev/null +++ b/server/channels/app/metrics.go @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +func (a *App) RegisterPerformanceReport(rctx request.CTX, report *model.PerformanceReport) *model.AppError { + if a.Metrics() == nil { + return nil + } + + commonLabels := report.ProcessLabels() + + for _, c := range report.Counters { + switch c.Metric { + case model.ClientLongTasks: + a.Metrics().IncrementClientLongTasks(commonLabels["platform"], commonLabels["agent"], float64(c.Value)) + default: + // we intentionally skip unknown metrics + } + } + + for _, h := range report.Histograms { + switch h.Metric { + case model.ClientTimeToFirstByte: + a.Metrics().ObserveClientTimeToFirstByte(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientFirstContentfulPaint: + a.Metrics().ObserveClientFirstContentfulPaint(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientLargestContentfulPaint: + a.Metrics().ObserveClientLargestContentfulPaint(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientInteractionToNextPaint: + a.Metrics().ObserveClientInteractionToNextPaint(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientCumulativeLayoutShift: + a.Metrics().ObserveClientCumulativeLayoutShift(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientChannelSwitchDuration: + a.Metrics().ObserveClientChannelSwitchDuration(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientTeamSwitchDuration: + a.Metrics().ObserveClientTeamSwitchDuration(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + case model.ClientRHSLoadDuration: + a.Metrics().ObserveClientRHSLoadDuration(commonLabels["platform"], commonLabels["agent"], float64(h.Value)) + default: + // we intentionally skip unknown metrics + } + } + + return nil +} diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 5e73aee526..218ba4faee 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -14095,6 +14095,28 @@ func (a *OpenTracingAppLayer) RegenerateTeamInviteId(teamID string) (*model.Team return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) RegisterPerformanceReport(rctx request.CTX, report *model.PerformanceReport) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RegisterPerformanceReport") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.RegisterPerformanceReport(rctx, report) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) RegisterPluginCommand(pluginID string, command *model.Command) error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RegisterPluginCommand") diff --git a/server/einterfaces/metrics.go b/server/einterfaces/metrics.go index 50cf797a0d..95993accaa 100644 --- a/server/einterfaces/metrics.go +++ b/server/einterfaces/metrics.go @@ -102,4 +102,14 @@ type MetricsInterface interface { IncrementNotificationErrorCounter(notificationType model.NotificationType, errorReason model.NotificationReason) IncrementNotificationNotSentCounter(notificationType model.NotificationType, notSentReason model.NotificationReason) IncrementNotificationUnsupportedCounter(notificationType model.NotificationType, notSentReason model.NotificationReason) + + ObserveClientTimeToFirstByte(platform, agent string, elapsed float64) + ObserveClientFirstContentfulPaint(platform, agent string, elapsed float64) + ObserveClientLargestContentfulPaint(platform, agent string, elapsed float64) + ObserveClientInteractionToNextPaint(platform, agent string, elapsed float64) + ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64) + IncrementClientLongTasks(platform, agent string, inc float64) + ObserveClientChannelSwitchDuration(platform, agent string, elapsed float64) + ObserveClientTeamSwitchDuration(platform, agent string, elapsed float64) + ObserveClientRHSLoadDuration(platform, agent string, elapsed float64) } diff --git a/server/einterfaces/mocks/MetricsInterface.go b/server/einterfaces/mocks/MetricsInterface.go index c8cbdd0152..628c636c35 100644 --- a/server/einterfaces/mocks/MetricsInterface.go +++ b/server/einterfaces/mocks/MetricsInterface.go @@ -73,6 +73,11 @@ func (_m *MetricsInterface) IncrementChannelIndexCounter() { _m.Called() } +// IncrementClientLongTasks provides a mock function with given fields: platform, agent, inc +func (_m *MetricsInterface) IncrementClientLongTasks(platform string, agent string, inc float64) { + _m.Called(platform, agent, inc) +} + // IncrementClusterEventType provides a mock function with given fields: eventType func (_m *MetricsInterface) IncrementClusterEventType(eventType model.ClusterEvent) { _m.Called(eventType) @@ -293,6 +298,46 @@ func (_m *MetricsInterface) ObserveAPIEndpointDuration(endpoint string, method s _m.Called(endpoint, method, statusCode, originClient, pageLoadContext, elapsed) } +// ObserveClientChannelSwitchDuration provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientChannelSwitchDuration(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientCumulativeLayoutShift provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientCumulativeLayoutShift(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientFirstContentfulPaint provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientFirstContentfulPaint(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientInteractionToNextPaint provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientInteractionToNextPaint(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientLargestContentfulPaint provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientLargestContentfulPaint(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientRHSLoadDuration provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientRHSLoadDuration(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientTeamSwitchDuration provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientTeamSwitchDuration(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + +// ObserveClientTimeToFirstByte provides a mock function with given fields: platform, agent, elapsed +func (_m *MetricsInterface) ObserveClientTimeToFirstByte(platform string, agent string, elapsed float64) { + _m.Called(platform, agent, elapsed) +} + // ObserveClusterRequestDuration provides a mock function with given fields: elapsed func (_m *MetricsInterface) ObserveClusterRequestDuration(elapsed float64) { _m.Called(elapsed) diff --git a/server/enterprise/metrics/metrics.go b/server/enterprise/metrics/metrics.go index 8c14651dec..26b9534874 100644 --- a/server/enterprise/metrics/metrics.go +++ b/server/enterprise/metrics/metrics.go @@ -42,6 +42,7 @@ const ( MetricsSubsystemSystem = "system" MetricsSubsystemJobs = "jobs" MetricsSubsystemNotifications = "notifications" + MetricsSubsystemClientsWeb = "webapp" MetricsCloudInstallationLabel = "installationId" MetricsCloudDatabaseClusterLabel = "databaseClusterName" MetricsCloudInstallationGroupLabel = "installationGroupId" @@ -209,6 +210,16 @@ type MetricsInterfaceImpl struct { NotificationErrorCounters *prometheus.CounterVec NotificationNotSentCounters *prometheus.CounterVec NotificationUnsupportedCounters *prometheus.CounterVec + + ClientTimeToFirstByte *prometheus.HistogramVec + ClientFirstContentfulPaint *prometheus.HistogramVec + ClientLargestContentfulPaint *prometheus.HistogramVec + ClientInteractionToNextPaint *prometheus.HistogramVec + ClientCumulativeLayoutShift *prometheus.HistogramVec + ClientLongTasks *prometheus.CounterVec + ClientChannelSwitchDuration *prometheus.HistogramVec + ClientTeamSwitchDuration *prometheus.HistogramVec + ClientRHSLoadDuration *prometheus.HistogramVec } func init() { @@ -1122,6 +1133,105 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf ) m.Registry.MustRegister(m.NotificationUnsupportedCounters) + m.ClientTimeToFirstByte = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "time_to_first_byte", + Help: "Duration from when a browser starts to request a page from a server until when it starts to receive data in response (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientTimeToFirstByte) + + m.ClientFirstContentfulPaint = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "first_contentful_paint", + Help: "Duration of how long it takes for any content to be displayed on screen to a user (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientFirstContentfulPaint) + + m.ClientLargestContentfulPaint = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "largest_contentful_paint", + Help: "Duration of how long it takes for large content to be displayed on screen to a user (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientLargestContentfulPaint) + + m.ClientInteractionToNextPaint = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "interaction_to_next_paint", + Help: "Measure of how long it takes for a user to see the effects of clicking with a mouse, tapping with a touchscreen, or pressing a key on the keyboard (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientInteractionToNextPaint) + + m.ClientCumulativeLayoutShift = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "cumulative_layout_shift", + Help: "Measure of how much a page's content shifts unexpectedly", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientCumulativeLayoutShift) + + m.ClientLongTasks = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "long_tasks", + Help: "Counter of the number of times that the browser's main UI thread is blocked for more than 50ms by a single task", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientLongTasks) + + m.ClientChannelSwitchDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "channel_switch", + Help: "Duration of the time taken from when a user clicks on a channel in the LHS to when posts in that channel become visible (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientChannelSwitchDuration) + + m.ClientTeamSwitchDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "team_switch", + Help: "Duration of the time taken from when a user clicks on a team in the LHS to when posts in that team become visible (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientTeamSwitchDuration) + + m.ClientRHSLoadDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Subsystem: MetricsSubsystemClientsWeb, + Name: "rhs_load", + Help: "Duration of the time taken from when a user clicks to open a thread in the RHS until when posts in that thread become visible (milliseconds)", + }, + []string{"platform", "user_agent"}, + ) + m.Registry.MustRegister(m.ClientRHSLoadDuration) + return m } @@ -1580,6 +1690,42 @@ func (mi *MetricsInterfaceImpl) DecrementHTTPWebSockets(originClient string) { mi.HTTPWebsocketsGauge.With(prometheus.Labels{"origin_client": originClient}).Dec() } +func (mi *MetricsInterfaceImpl) ObserveClientTimeToFirstByte(platform, agent string, elapsed float64) { + mi.ClientTimeToFirstByte.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientFirstContentfulPaint(platform, agent string, elapsed float64) { + mi.ClientFirstContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientLargestContentfulPaint(platform, agent string, elapsed float64) { + mi.ClientLargestContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientInteractionToNextPaint(platform, agent string, elapsed float64) { + mi.ClientInteractionToNextPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64) { + mi.ClientCumulativeLayoutShift.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) IncrementClientLongTasks(platform, agent string, inc float64) { + mi.ClientLongTasks.With(prometheus.Labels{"platform": platform, "agent": agent}).Add(inc) +} + +func (mi *MetricsInterfaceImpl) ObserveClientChannelSwitchDuration(platform, agent string, elapsed float64) { + mi.ClientChannelSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientTeamSwitchDuration(platform, agent string, elapsed float64) { + mi.ClientTeamSwitchDuration.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + +func (mi *MetricsInterfaceImpl) ObserveClientRHSLoadDuration(platform, agent string, elapsed float64) { + mi.ClientRHSLoadDuration.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +} + func extractDBCluster(driver, connectionString string) (string, error) { host, err := extractHost(driver, connectionString) if err != nil { diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 85008487be..f1eea7d798 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -588,6 +588,10 @@ func (c *Client4) bookmarkRoute(channelId, bookmarkId string) string { return fmt.Sprintf(c.bookmarksRoute(channelId)+"/%v", bookmarkId) } +func (c *Client4) perfMetricsRoute() string { + return "/perf" +} + func (c *Client4) DoAPIGet(ctx context.Context, url string, etag string) (*http.Response, error) { return c.DoAPIRequest(ctx, http.MethodGet, c.APIURL+url, "", etag) } @@ -8848,3 +8852,16 @@ func (c *Client4) ListChannelBookmarksForChannel(ctx context.Context, channelId } return b, BuildResponse(r), nil } + +func (c *Client4) SubmitClientMetrics(ctx context.Context, report *PerformanceReport) (*Response, error) { + buf, err := json.Marshal(report) + if err != nil { + return nil, NewAppError("SubmitClientMetrics", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + res, err := c.DoAPIPostBytes(ctx, c.perfMetricsRoute(), buf) + if err != nil { + return BuildResponse(res), err + } + + return BuildResponse(res), nil +} diff --git a/server/public/model/metrics.go b/server/public/model/metrics.go new file mode 100644 index 0000000000..cea7a7f4a2 --- /dev/null +++ b/server/public/model/metrics.go @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "fmt" + "strings" + "time" + + "github.com/blang/semver/v4" +) + +type MetricType string + +const ( + ClientTimeToFirstByte MetricType = "TTFB" + ClientFirstContentfulPaint MetricType = "FCP" + ClientLargestContentfulPaint MetricType = "LCP" + ClientInteractionToNextPaint MetricType = "INP" + ClientCumulativeLayoutShift MetricType = "CLS" + ClientLongTasks MetricType = "long_tasks" + ClientChannelSwitchDuration MetricType = "channel_switch" + ClientTeamSwitchDuration MetricType = "team_switch" + ClientRHSLoadDuration MetricType = "rhs_load" + + performanceReportTTLMilliseconds = 300 * 1000 // 300 seconds/5 minutes +) + +var ( + performanceReportVersion = semver.MustParse("0.1.0") + acceptedPlatforms = sliceToMapKey("linux", "macos", "ios", "android", "windows", "other") + acceptedAgents = sliceToMapKey("desktop", "firefox", "chrome", "safari", "edge", "other") +) + +type MetricSample struct { + Metric MetricType `json:"metric"` + Value int64 `json:"value"` + Timestamp int64 `json:"timestamp,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// PerformanceReport is a set of samples collected from a client +type PerformanceReport struct { + Version string `json:"version"` + ClientID string `json:"client_id"` + Labels map[string]string `json:"labels"` + Start int64 `json:"start"` + End int64 `json:"end"` + Counters []*MetricSample `json:"counters"` + Histograms []*MetricSample `json:"histograms"` +} + +func (r *PerformanceReport) IsValid() error { + if r == nil { + return fmt.Errorf("the report is nil") + } + + reportVersion, err := semver.ParseTolerant(r.Version) + if err != nil { + return err + } + + if reportVersion.Major != performanceReportVersion.Major || reportVersion.Minor > performanceReportVersion.Minor { + return fmt.Errorf("report version is not supported: server version: %s, report version: %s", performanceReportVersion.String(), r.Version) + } + + if r.Start >= r.End { + return fmt.Errorf("report timestamps are erroneous") + } + + now := time.Now().UnixMilli() + if r.End < now-performanceReportTTLMilliseconds { + return fmt.Errorf("report is outdated: %d", r.End) + } + + return nil +} + +func (r *PerformanceReport) ProcessLabels() map[string]string { + var platform, agent string + var ok bool + + // check if the platform is specified + platform, ok = r.Labels["platform"] + if !ok { + platform = "other" + } + platform = strings.ToLower(platform) + + // check if platform is one of the accepted platforms + _, ok = acceptedPlatforms[platform] + if !ok { + platform = "other" + } + + // check if the agent is specified + agent, ok = r.Labels["agent"] + if !ok { + agent = "other" + } + agent = strings.ToLower(agent) + + // check if agent is one of the accepted agents + _, ok = acceptedAgents[agent] + if !ok { + agent = "other" + } + + return map[string]string{ + "platform": platform, + "agent": agent, + } +} diff --git a/server/public/model/metrics_test.go b/server/public/model/metrics_test.go new file mode 100644 index 0000000000..55937ef0c3 --- /dev/null +++ b/server/public/model/metrics_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPerformanceReport_IsValid(t *testing.T) { + outdatedTimestamp := time.Now().Add(-6 * time.Minute).UnixMilli() + tests := []struct { + name string + report *PerformanceReport + expected error + }{ + { + name: "ValidReport", + report: &PerformanceReport{ + Version: "0.1.0", + Labels: map[string]string{"platform": "linux"}, + Start: time.Now().UnixMilli() - 10000, + End: time.Now().UnixMilli(), + }, + expected: nil, + }, + { + name: "NilReport", + report: nil, + expected: fmt.Errorf("the report is nil"), + }, + { + name: "UnsupportedVersion", + report: &PerformanceReport{ + Version: "2.0.0", + Labels: map[string]string{"platform": "linux"}, + Start: time.Now().UnixMilli() - 10000, + End: time.Now().UnixMilli(), + }, + expected: fmt.Errorf("report version is not supported: server version: 0.1.0, report version: 2.0.0"), + }, + { + name: "ErroneousTimestamps", + report: &PerformanceReport{ + Version: "0.1.0", + Labels: map[string]string{"platform": "linux"}, + Start: time.Now().UnixMilli(), + End: time.Now().Add(-1 * time.Hour).UnixMilli(), + }, + expected: fmt.Errorf("report timestamps are erroneous"), + }, + { + name: "OutdatedReport", + report: &PerformanceReport{ + Version: "0.1.0", + Labels: map[string]string{"platform": "linux"}, + Start: time.Now().Add(-7 * time.Minute).UnixMilli(), + End: outdatedTimestamp, + }, + expected: fmt.Errorf("report is outdated: %d", outdatedTimestamp), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.report.IsValid() + if tt.expected != nil { + require.EqualError(t, err, tt.expected.Error()) + return + } + require.NoError(t, err) + }) + } +} diff --git a/server/public/model/utils.go b/server/public/model/utils.go index 2d128b0a91..258334db49 100644 --- a/server/public/model/utils.go +++ b/server/public/model/utils.go @@ -844,3 +844,16 @@ func filterBlocklist(r rune) rune { func IsCloud() bool { return os.Getenv("MM_CLOUD_INSTALLATION_ID") != "" } + +func sliceToMapKey(s ...string) map[string]any { + m := make(map[string]any) + for i := range s { + m[s[i]] = struct{}{} + } + + if len(s) != len(m) { + panic("duplicate keys") + } + + return m +} diff --git a/server/scripts/vet-api-check.sh b/server/scripts/vet-api-check.sh index 91823c6461..a8713f48cf 100755 --- a/server/scripts/vet-api-check.sh +++ b/server/scripts/vet-api-check.sh @@ -43,6 +43,7 @@ OUTPUT_EXCLUDING_IGNORED=$(echo "$OUTPUT" | grep -Fv \ -e 'Cannot find /api/v4/hosted_customer/subscribe-newsletter method: POST in OpenAPI 3 spec.' \ -e 'Cannot find /api/v4/license/review method: POST in OpenAPI 3 spec.' \ -e 'Cannot find /api/v4/license/review/status method: GET in OpenAPI 3 spec.' \ + -e 'Cannot find /api/v4/perf method: POST in OpenAPI 3 spec.' \ -e 'Cannot find /api/v4/posts/{post_id}/edit_history method: GET in OpenAPI 3 spec.' \ -e 'Cannot find /api/v4/posts/{post_id}/info method: GET in OpenAPI 3 spec.' \ -e 'Cannot find /api/v4/posts/search method: POST in OpenAPI 3 spec.' \