Add new Metrics API (#26919)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
099f704d4f
Коммит
5590e1604a
@@ -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))
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
38
server/channels/api4/metrics.go
Обычный файл
38
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
|
||||
}
|
||||
}
|
||||
158
server/channels/api4/metrics_test.go
Обычный файл
158
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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
51
server/channels/app/metrics.go
Обычный файл
51
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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
114
server/public/model/metrics.go
Обычный файл
114
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,
|
||||
}
|
||||
}
|
||||
78
server/public/model/metrics_test.go
Обычный файл
78
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.' \
|
||||
|
||||
Ссылка в новой задаче
Block a user