diff --git a/server/channels/api4/group.go b/server/channels/api4/group.go index 3c9aad6d2b..5af1d20f13 100644 --- a/server/channels/api4/group.go +++ b/server/channels/api4/group.go @@ -18,6 +18,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/channels/web" + "github.com/mattermost/mattermost/server/v8/platform/services/telemetry" ) func (api *API) InitGroup() { @@ -301,6 +302,14 @@ func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventResultState(group) auditRec.AddEventObjectType("group") + c.App.Srv().GetTelemetryService().SendTelemetryForFeature( + telemetry.TrackGroupsFeature, + "modify_group__edit_details", + map[string]any{ + telemetry.TrackPropertyUser: c.AppContext.Session().UserId, + telemetry.TrackPropertyGroup: group.Id, + }) + b, err := json.Marshal(group) if err != nil { c.Err = model.NewAppError("Api4.patchGroup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -1339,6 +1348,13 @@ func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { if _, err := w.Write(b); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + c.App.Srv().GetTelemetryService().SendTelemetryForFeature( + telemetry.TrackGroupsFeature, + "modify_group__add_members", + map[string]any{ + telemetry.TrackPropertyUser: c.AppContext.Session().UserId, + telemetry.TrackPropertyGroup: group.Id, + }) } func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1400,6 +1416,13 @@ func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { if _, err := w.Write(b); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + c.App.Srv().GetTelemetryService().SendTelemetryForFeature( + telemetry.TrackGroupsFeature, + "modify_group__remove_members", + map[string]any{ + telemetry.TrackPropertyUser: c.AppContext.Session().UserId, + telemetry.TrackPropertyGroup: group.Id, + }) } // hasPermissionToReadGroupMembers check if a user has the permission to read the list of members of a given team. diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index a724d7c5ef..7d8a086f0a 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -1585,7 +1585,7 @@ func (a *App) addUserToChannel(c request.CTX, user *model.User, channel *model.C a.Srv().telemetryService.SendTelemetryForFeature( telemetry.TrackGuestFeature, "add_guest_to_channel", - map[string]any{"user_actual_id": user.Id}) + map[string]any{telemetry.TrackPropertyUser: user.Id}) } a.Srv().Platform().InvalidateChannelCacheForUser(user.Id) @@ -2663,10 +2663,12 @@ func (a *App) SetActiveChannel(c request.CTX, userID string, channelID string) * oldStatus := model.StatusOffline + oldChannelID := "" if err != nil { status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: channelID} } else { oldStatus = status.Status + oldChannelID = status.ActiveChannel status.ActiveChannel = channelID if !status.Manual && channelID != "" { status.Status = model.StatusOnline @@ -2680,6 +2682,24 @@ func (a *App) SetActiveChannel(c request.CTX, userID string, channelID string) * a.Srv().Platform().BroadcastStatus(status) } + if channelID != "" && oldChannelID != channelID { + // is this a read-only channel? + isReadOnly, ircErr := a.Srv().Store().Channel().IsReadOnlyChannel(channelID) + if ircErr != nil { + mlog.Warn("Error trying to check if it is a readonly channel", mlog.Err(ircErr)) + } + if isReadOnly { + a.Srv().telemetryService.SendTelemetryForFeature( + telemetry.TrackReadOnlyFeature, + "read_only_channel_viewed", + map[string]any{ + telemetry.TrackPropertyUser: userID, + telemetry.TrackPropertyChannel: channelID, + }, + ) + } + } + return nil } diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index 5472278f8e..5ee27a25d2 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -869,17 +869,20 @@ func (a *App) SendNotifications(c request.CTX, post *model.Post, team *model.Tea a.Srv().telemetryService.SendTelemetryForFeature( telemetry.TrackGuestFeature, "post_mentioned_guest", - map[string]any{"user_actual_id": user.Id, "post_owner_id": sender.Id}, + map[string]any{telemetry.TrackPropertyUser: user.Id, telemetry.TrackPropertyPostAuthor: sender.Id}, ) } else if reason == DMMention { a.Srv().telemetryService.SendTelemetryForFeature( telemetry.TrackGuestFeature, "direct_message_to_guest", - map[string]any{"user_actual_id": user.Id, "post_owner_id": sender.Id}, + map[string]any{telemetry.TrackPropertyUser: user.Id, telemetry.TrackPropertyPostAuthor: sender.Id}, ) } } } + for groupId := range mentions.GroupMentions { + a.Srv().telemetryService.SendTelemetryForFeature(telemetry.TrackGroupsFeature, "post_mentioned_custom_group", map[string]any{telemetry.TrackPropertyUser: sender.Id, telemetry.TrackPropertyGroup: groupId, "group_size": groups[groupId].MemberCount}) + } return mentionedUsersList, nil } @@ -1576,6 +1579,13 @@ func (a *App) insertGroupMentions(senderID string, group *model.Group, channel * for _, user := range outOfChannelGroupMembers { potentialGroupMembersMentioned = append(potentialGroupMembersMentioned, user.Username) } + if len(potentialGroupMembersMentioned) != 0 { + a.Srv().telemetryService.SendTelemetryForFeature( + telemetry.TrackGroupsFeature, + "invite_group_to_channel__post", + map[string]any{telemetry.TrackPropertyUser: senderID, telemetry.TrackPropertyGroup: group.Id}, + ) + } if mentions.OtherPotentialMentions == nil { mentions.OtherPotentialMentions = potentialGroupMembersMentioned } else { diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 7eae544cb0..100f54ad17 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -23,6 +23,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/channels/store/sqlstore" "github.com/mattermost/mattermost/server/v8/platform/services/cache" + "github.com/mattermost/mattermost/server/v8/platform/services/telemetry" ) const ( @@ -79,6 +80,22 @@ func (a *App) CreatePostAsUser(c request.CTX, post *model.Post, currentSessionId ) } } + if channel.SchemeId != nil && *channel.SchemeId != "" { + isReadOnly, ircErr := a.Srv().Store().Channel().IsChannelReadOnlyScheme(*channel.SchemeId) + if ircErr != nil { + mlog.Warn("Error trying to check if it was a post to a readonly channel", mlog.Err(ircErr)) + } + if isReadOnly { + a.Srv().telemetryService.SendTelemetryForFeature( + telemetry.TrackReadOnlyFeature, + "read_only_channel_posted", + map[string]any{ + telemetry.TrackPropertyUser: post.UserId, + telemetry.TrackPropertyChannel: post.ChannelId, + }, + ) + } + } return rp, nil } diff --git a/server/channels/app/slashcommands/command_invite.go b/server/channels/app/slashcommands/command_invite.go index 4964a5ee13..4400ea565a 100644 --- a/server/channels/app/slashcommands/command_invite.go +++ b/server/channels/app/slashcommands/command_invite.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/platform/services/telemetry" ) type InviteProvider struct { @@ -185,23 +186,23 @@ func (i *InviteProvider) doCommand(a *app.App, c request.CTX, args *model.Comman return "" } -func (i *InviteProvider) getUsersFromMentionName(a *app.App, mentionName string) []*model.User { +func (i *InviteProvider) getUsersFromMentionName(a *app.App, mentionName string) ([]*model.User, *model.Group) { userProfile, err := a.Srv().Store().User().GetByUsername(mentionName) if err == nil && userProfile.DeleteAt == 0 { - return []*model.User{userProfile} + return []*model.User{userProfile}, nil } group, appErr := a.GetGroupByName(mentionName, model.GroupSearchOpts{FilterAllowReference: true}) if appErr != nil || group == nil { - return nil + return nil, nil } members, appErr := a.GetGroupMemberUsers(group.Id) if appErr != nil { - return nil + return nil, nil } - return members + return members, group } func (i *InviteProvider) parseMessage(a *app.App, c request.CTX, args *model.CommandArgs, resps *[]string, message string) ([]*model.User, []*model.Channel, string) { @@ -217,7 +218,14 @@ func (i *InviteProvider) parseMessage(a *app.App, c request.CTX, args *model.Com if msg[0] == '@' || (msg[0] != '~' && j == 0) { targetMentionName := strings.TrimPrefix(msg, "@") - users := i.getUsersFromMentionName(a, targetMentionName) + users, group := i.getUsersFromMentionName(a, targetMentionName) + if group != nil { + a.Srv().GetTelemetryService().SendTelemetryForFeature( + telemetry.TrackGroupsFeature, + "invite_group_to_channel__command", + map[string]any{telemetry.TrackPropertyUser: c.Session().UserId, telemetry.TrackPropertyGroup: group.Id}, + ) + } if len(users) == 0 { *resps = append(*resps, args.T("api.command_invite.missing_user.app_error", map[string]any{ "User": targetMentionName, diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 17fed03d06..1f8808272a 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -2059,6 +2059,42 @@ func (s *OpenTracingLayerChannelStore) InvalidatePinnedPostCount(channelID strin } +func (s *OpenTracingLayerChannelStore) IsChannelReadOnlyScheme(schemeID string) (bool, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.IsChannelReadOnlyScheme") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.IsChannelReadOnlyScheme(schemeID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerChannelStore) IsReadOnlyChannel(channelID string) (bool, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.IsReadOnlyChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.IsReadOnlyChannel(channelID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelStore) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.MigrateChannelMembers") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 6c9c42e213..145c93b991 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -2252,6 +2252,48 @@ func (s *RetryLayerChannelStore) InvalidatePinnedPostCount(channelID string) { } +func (s *RetryLayerChannelStore) IsChannelReadOnlyScheme(schemeID string) (bool, error) { + + tries := 0 + for { + result, err := s.ChannelStore.IsChannelReadOnlyScheme(schemeID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelStore) IsReadOnlyChannel(channelID string) (bool, error) { + + tries := 0 + for { + result, err := s.ChannelStore.IsReadOnlyChannel(channelID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) { tries := 0 diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 052009c757..ac8344145d 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/einterfaces" @@ -4322,3 +4323,40 @@ func (s SqlChannelStore) GetTeamForChannel(channelID string) (*model.Team, error } return &team, nil } + +func (s SqlChannelStore) IsReadOnlyChannel(channelID string) (bool, error) { + query := s.getQueryBuilder().Select("schemeid").From("channels").Where(sq.Eq{"id": channelID}).Limit(1) + squery, args, err := query.ToSql() + if err != nil { + return false, err + } + // we look for schemeID to look for a custom scheme, if there is none chances are it is a writeable + // there might be in effect a custom scheme for the user that doesn't allow to create posts, but that wouldn't + // be a readonly channel but a readonly user + var schemaId string + err = s.GetReplicaX().Get(&schemaId, squery, args...) + if err != nil { + return false, nil + } + if schemaId == "" { + return false, nil + } + return s.IsChannelReadOnlyScheme(schemaId) +} + +func (s SqlChannelStore) IsChannelReadOnlyScheme(schemeID string) (bool, error) { + query := s.getQueryBuilder().Select("roles.permissions").From("roles").InnerJoin("schemes ON roles.name = schemes.defaultchanneluserrole").Where(sq.Eq{"schemes.id": schemeID}).Limit(1) + squery, args, err := query.ToSql() + if err != nil { + mlog.Err(err) + return false, err + } + var permissions string + err = s.GetReplicaX().Get(&permissions, squery, args...) + if err != nil { + mlog.Err(err) + return false, err + } + permissionList := strings.Split(permissions, " ") + return slices.Index(permissionList, model.PermissionCreatePost.Id) == -1, nil +} diff --git a/server/channels/store/store.go b/server/channels/store/store.go index d0ca780484..9f428bc155 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -310,6 +310,8 @@ type ChannelStore interface { SetShared(channelId string, shared bool) error // GetTeamForChannel returns the team for a given channelID. GetTeamForChannel(channelID string) (*model.Team, error) + IsReadOnlyChannel(channelID string) (bool, error) + IsChannelReadOnlyScheme(schemeID string) (bool, error) } type ChannelMemberHistoryStore interface { diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 489151e9cb..4927cd9c0f 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -2199,6 +2199,62 @@ func (_m *ChannelStore) InvalidatePinnedPostCount(channelID string) { _m.Called(channelID) } +// IsChannelReadOnlyScheme provides a mock function with given fields: schemeID +func (_m *ChannelStore) IsChannelReadOnlyScheme(schemeID string) (bool, error) { + ret := _m.Called(schemeID) + + if len(ret) == 0 { + panic("no return value specified for IsChannelReadOnlyScheme") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string) (bool, error)); ok { + return rf(schemeID) + } + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(schemeID) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(schemeID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsReadOnlyChannel provides a mock function with given fields: channelID +func (_m *ChannelStore) IsReadOnlyChannel(channelID string) (bool, error) { + ret := _m.Called(channelID) + + if len(ret) == 0 { + panic("no return value specified for IsReadOnlyChannel") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string) (bool, error)); ok { + return rf(channelID) + } + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(channelID) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(channelID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // MigrateChannelMembers provides a mock function with given fields: fromChannelID, fromUserID func (_m *ChannelStore) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) { ret := _m.Called(fromChannelID, fromUserID) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index b91949f031..97a733f45d 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -1918,6 +1918,38 @@ func (s *TimerLayerChannelStore) InvalidatePinnedPostCount(channelID string) { } } +func (s *TimerLayerChannelStore) IsChannelReadOnlyScheme(schemeID string) (bool, error) { + start := time.Now() + + result, err := s.ChannelStore.IsChannelReadOnlyScheme(schemeID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.IsChannelReadOnlyScheme", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelStore) IsReadOnlyChannel(channelID string) (bool, error) { + start := time.Now() + + result, err := s.ChannelStore.IsReadOnlyChannel(channelID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.IsReadOnlyChannel", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) MigrateChannelMembers(fromChannelID string, fromUserID string) (map[string]string, error) { start := time.Now() diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 1baa5e86c1..bca9d27e24 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -102,7 +102,18 @@ const ( type TrackFeature string -const TrackGuestFeature TrackFeature = "guest_accounts" +const ( + TrackGuestFeature TrackFeature = "guest_accounts" + TrackGroupsFeature TrackFeature = "custom_groups" + TrackReadOnlyFeature TrackFeature = "read_only_channels" +) + +const ( + TrackPropertyUser = "user_actual_id" + TrackPropertyGroup = "group_id" + TrackPropertyChannel = "channel_id" + TrackPropertyPostAuthor = "post_owner_id" +) type ServerIface interface { Config() *model.Config @@ -136,7 +147,9 @@ type EventFeature struct { } var featureSKUS = map[TrackFeature][]TrackSKU{ - TrackGuestFeature: {TrackProfessionalSKU, TrackEnterpriseSKU}, + TrackGuestFeature: {TrackProfessionalSKU, TrackEnterpriseSKU}, + TrackGroupsFeature: {TrackProfessionalSKU, TrackEnterpriseSKU}, + TrackReadOnlyFeature: {TrackProfessionalSKU, TrackEnterpriseSKU}, } func New(srv ServerIface, dbStore store.Store, searchEngine *searchengine.Broker, log *mlog.Logger, verbose bool) (*TelemetryService, error) { diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/telemetry.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/telemetry.ts index 9d30c293e6..82907607c2 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/telemetry.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/telemetry.ts @@ -10,7 +10,7 @@ export const TrackGroupsFeature = 'custom_groups'; export const TrackPassiveKeywordsFeature = 'passive_keywords'; // Events -export const TrackInviteGroupEvent = 'invite_group_to_channel'; +export const TrackInviteGroupEvent = 'invite_group_to_channel__add_member'; export const TrackPassiveKeywordsEvent = 'update_passive_keywords'; // Categories