feat(BRIDGE-150): Observability service modification; user distinction utility & heartbeat; various observbility metrics & relevant integration tests

This commit is contained in:
Atanas Janeshliev
2024-09-23 10:13:05 +00:00
parent 5b874657cb
commit 3ca9e625f5
30 changed files with 1348 additions and 106 deletions

View File

@ -0,0 +1,47 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import "time"
// DistinctionErrorTypeEnum - maps to the specific error schema for which we
// want to send a user update.
type DistinctionErrorTypeEnum int
const (
SyncError DistinctionErrorTypeEnum = iota
EventLoopError
)
// errorSchemaMap - maps between the DistinctionErrorTypeEnum and the relevant schema name.
var errorSchemaMap = map[DistinctionErrorTypeEnum]string{ //nolint:gochecknoglobals
SyncError: "bridge_sync_errors_users_total",
EventLoopError: "bridge_event_loop_events_errors_users_total",
}
// createLastSentMap - needs to be updated whenever we make changes to the enum.
func createLastSentMap() map[DistinctionErrorTypeEnum]time.Time {
registerTime := time.Now().Add(-updateInterval)
lastSentMap := make(map[DistinctionErrorTypeEnum]time.Time)
for errType := SyncError; errType <= EventLoopError; errType++ {
lastSentMap[errType] = registerTime
}
return lastSentMap
}

View File

@ -0,0 +1,170 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"context"
"sync"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
)
var updateInterval = time.Minute * 5 //nolint:gochecknoglobals
type observabilitySender interface {
addMetricsIfClients(metric ...proton.ObservabilityMetric)
}
// distinctionUtility - used to discern whether X number of events stem from Y number of users.
type distinctionUtility struct {
ctx context.Context
panicHandler async.PanicHandler
lastSentMap map[DistinctionErrorTypeEnum]time.Time // Ensures we don't step over the limit of one user update every 5 mins.
observabilitySender observabilitySender
settingsGetter settingsGetter
userPlanUnsafe string
userPlanLock sync.Mutex
heartbeatData heartbeatData
heartbeatDataLock sync.Mutex
heartbeatTicker *time.Ticker
}
func newDistinctionUtility(ctx context.Context, panicHandler async.PanicHandler, observabilitySender observabilitySender) *distinctionUtility {
distinctionUtility := &distinctionUtility{
ctx: ctx,
panicHandler: panicHandler,
lastSentMap: createLastSentMap(),
observabilitySender: observabilitySender,
userPlanUnsafe: planUnknown,
heartbeatData: heartbeatData{},
heartbeatTicker: time.NewTicker(updateInterval),
}
return distinctionUtility
}
// sendMetricsWithGuard - schedules the metrics to be sent only if there are authenticated clients.
func (d *distinctionUtility) sendMetricsWithGuard(metrics ...proton.ObservabilityMetric) {
if d.observabilitySender == nil {
return
}
d.observabilitySender.addMetricsIfClients(metrics...)
}
func (d *distinctionUtility) setSettingsGetter(getter settingsGetter) {
d.settingsGetter = getter
}
// checkAndUpdateLastSentMap - checks whether we have sent a relevant user update metric
// within the last 5 minutes.
func (d *distinctionUtility) checkAndUpdateLastSentMap(key DistinctionErrorTypeEnum) bool {
curTime := time.Now()
val, ok := d.lastSentMap[key]
if !ok {
d.lastSentMap[key] = curTime
return true
}
if val.Add(updateInterval).Before(curTime) {
d.lastSentMap[key] = curTime
return true
}
return false
}
// generateUserMetric creates the relevant user update metric based on its type
// and the relevant settings. In the future this will need to be expanded to support multiple
// versions of the metric if we ever decide to change them.
func (d *distinctionUtility) generateUserMetric(
metricType DistinctionErrorTypeEnum,
) proton.ObservabilityMetric {
schemaName, ok := errorSchemaMap[metricType]
if !ok {
return proton.ObservabilityMetric{}
}
return generateUserMetric(schemaName, d.getUserPlanSafe(),
d.getEmailClientUserAgent(),
getEnabled(d.getProxyAllowed()),
getEnabled(d.getBetaAccessEnabled()),
)
}
func generateUserMetric(schemaName, plan, mailClient, dohEnabled, betaAccess string) proton.ObservabilityMetric {
return proton.ObservabilityMetric{
Name: schemaName,
Version: 1,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"Value": 1,
"Labels": map[string]string{
"plan": plan,
"mailClient": mailClient,
"dohEnabled": dohEnabled,
"betaAccessEnabled": betaAccess,
},
},
}
}
func (d *distinctionUtility) generateDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) []proton.ObservabilityMetric {
d.updateHeartbeatData(errType)
if d.checkAndUpdateLastSentMap(errType) {
metrics = append(metrics, d.generateUserMetric(errType))
}
return metrics
}
func (d *distinctionUtility) getEmailClientUserAgent() string {
ua := ""
if d.settingsGetter != nil {
ua = d.settingsGetter.GetCurrentUserAgent()
}
return matchUserAgent(ua)
}
func (d *distinctionUtility) getBetaAccessEnabled() bool {
if d.settingsGetter == nil {
return false
}
return d.settingsGetter.GetUpdateChannel() == updater.EarlyChannel
}
func (d *distinctionUtility) getProxyAllowed() bool {
if d.settingsGetter == nil {
return false
}
return d.settingsGetter.GetProxyAllowed()
}

View File

@ -0,0 +1,119 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"fmt"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
)
const genericHeartbeatSchemaName = "bridge_generic_user_heartbeat_total"
type heartbeatData struct {
receivedSyncError bool
receivedEventLoopError bool
receivedOtherError bool
}
func (d *distinctionUtility) resetHeartbeatData() {
d.heartbeatData.receivedSyncError = false
d.heartbeatData.receivedOtherError = false
d.heartbeatData.receivedEventLoopError = false
}
func (d *distinctionUtility) updateHeartbeatData(errType DistinctionErrorTypeEnum) {
d.withUpdateHeartbeatDataLock(func() {
switch errType {
case SyncError:
d.heartbeatData.receivedSyncError = true
case EventLoopError:
d.heartbeatData.receivedEventLoopError = true
}
})
}
func (d *distinctionUtility) runHeartbeat() {
go func() {
defer async.HandlePanic(d.panicHandler)
defer d.heartbeatTicker.Stop()
for {
select {
case <-d.ctx.Done():
return
case <-d.heartbeatTicker.C:
d.sendHeartbeat()
}
}
}()
}
func (d *distinctionUtility) withUpdateHeartbeatDataLock(fn func()) {
d.heartbeatDataLock.Lock()
defer d.heartbeatDataLock.Unlock()
fn()
}
// sendHeartbeat - will only send a heartbeat if there is an authenticated client
// otherwise we might end up polluting the cache and therefore our metrics.
func (d *distinctionUtility) sendHeartbeat() {
d.withUpdateHeartbeatDataLock(func() {
d.sendMetricsWithGuard(d.generateHeartbeatUserMetric())
d.resetHeartbeatData()
})
}
func formatBool(value bool) string {
return fmt.Sprintf("%t", value)
}
// generateHeartbeatUserMetric creates the heartbeat user metric and includes the relevant data.
func (d *distinctionUtility) generateHeartbeatUserMetric() proton.ObservabilityMetric {
return generateHeartbeatMetric(
d.getUserPlanSafe(),
d.getEmailClientUserAgent(),
getEnabled(d.settingsGetter.GetProxyAllowed()),
getEnabled(d.getBetaAccessEnabled()),
formatBool(d.heartbeatData.receivedOtherError),
formatBool(d.heartbeatData.receivedSyncError),
formatBool(d.heartbeatData.receivedEventLoopError),
)
}
func generateHeartbeatMetric(plan, mailClient, dohEnabled, betaAccess, otherError, syncError, eventLoopError string) proton.ObservabilityMetric {
return proton.ObservabilityMetric{
Name: genericHeartbeatSchemaName,
Version: 1,
Timestamp: time.Now().Unix(),
Data: map[string]interface{}{
"Value": 1,
"Labels": map[string]string{
"plan": plan,
"mailClient": mailClient,
"dohEnabled": dohEnabled,
"betaAccessEnabled": betaAccess,
"receivedOtherError": otherError,
"receivedSyncError": syncError,
"receivedEventLoopError": eventLoopError,
},
},
}
}

View File

@ -0,0 +1,121 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"context"
"strings"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/go-proton-api"
)
const (
planUnknown = "unknown"
planOther = "other"
planBusiness = "business"
planIndividual = "individual"
planGroup = "group"
)
var planHierarchy = map[string]int{ //nolint:gochecknoglobals
planBusiness: 4,
planGroup: 3,
planIndividual: 2,
planOther: 1,
planUnknown: 0,
}
type planGetter interface {
GetOrganizationData(ctx context.Context) (proton.OrganizationResponse, error)
}
func isHigherPriority(currentPlan, newPlan string) bool {
newRank, ok := planHierarchy[newPlan]
if !ok {
return false
}
currentRank, ok2 := planHierarchy[currentPlan]
if !ok2 {
return true // we don't have a valid plan, might as well replace it
}
return newRank > currentRank
}
func mapUserPlan(planName string) string {
if planName == "" {
return planUnknown
}
switch strings.TrimSpace(strings.ToLower(planName)) {
case "mail2022":
return planIndividual
case "bundle2022":
return planIndividual
case "family2022":
return planGroup
case "visionary2022":
return planGroup
case "mailpro2022":
return planBusiness
case "planbiz2024":
return planBusiness
case "bundlepro2022":
return planBusiness
case "bundlepro2024":
return planBusiness
case "duo2024":
return planGroup
default:
return planOther
}
}
func (d *distinctionUtility) setUserPlan(planName string) {
if planName == "" {
return
}
d.userPlanLock.Lock()
defer d.userPlanLock.Unlock()
userPlanMapped := mapUserPlan(planName)
if isHigherPriority(d.userPlanUnsafe, userPlanMapped) {
d.userPlanUnsafe = userPlanMapped
}
}
func (d *distinctionUtility) registerUserPlan(ctx context.Context, getter planGetter, panicHandler async.PanicHandler) {
go func() {
defer async.HandlePanic(panicHandler)
orgRes, err := getter.GetOrganizationData(ctx)
if err != nil {
return
}
d.setUserPlan(orgRes.Organization.PlanName)
}()
}
func (d *distinctionUtility) getUserPlanSafe() string {
d.userPlanLock.Lock()
defer d.userPlanLock.Unlock()
return d.userPlanUnsafe
}

View File

@ -36,13 +36,18 @@ const (
maxBatchSize = 1000
)
type PushObsMetricFn func(metric proton.ObservabilityMetric)
type client struct {
isTelemetryEnabled func(context.Context) bool
sendMetrics func(context.Context, proton.ObservabilityBatch) error
}
// Sender - interface maps to the observability service methods,
// so we can easily pass them down to relevant components.
type Sender interface {
AddMetrics(metrics ...proton.ObservabilityMetric)
AddDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric)
}
type Service struct {
ctx context.Context
cancel context.CancelFunc
@ -62,6 +67,8 @@ type Service struct {
userClientStore map[string]*client
userClientStoreLock sync.Mutex
distinctionUtility *distinctionUtility
}
func NewService(ctx context.Context, panicHandler async.PanicHandler) *Service {
@ -85,11 +92,19 @@ func NewService(ctx context.Context, panicHandler async.PanicHandler) *Service {
userClientStore: make(map[string]*client),
}
service.distinctionUtility = newDistinctionUtility(ctx, panicHandler, service)
return service
}
func (s *Service) Run() {
// Run starts the observability service goroutine.
// The function also sets some utility functions to a helper struct aimed at differentiating the amount of users sending metric updates.
func (s *Service) Run(settingsGetter settingsGetter) {
s.log.Info("Starting service")
s.distinctionUtility.setSettingsGetter(settingsGetter)
s.distinctionUtility.runHeartbeat()
go func() {
s.start()
}()
@ -200,7 +215,7 @@ func (s *Service) scheduleDispatch() {
}()
}
func (s *Service) AddMetric(metric proton.ObservabilityMetric) {
func (s *Service) addMetrics(metric ...proton.ObservabilityMetric) {
s.withMetricStoreLock(func() {
metricStoreLength := len(s.metricStore)
if metricStoreLength >= maxStorageSize {
@ -209,12 +224,32 @@ func (s *Service) AddMetric(metric proton.ObservabilityMetric) {
dropCount := metricStoreLength - maxStorageSize + 1
s.metricStore = s.metricStore[dropCount:]
}
s.metricStore = append(s.metricStore, metric)
s.metricStore = append(s.metricStore, metric...)
})
// If the context has been cancelled i.e. the service has been stopped then we should be free to exit.
if s.ctx.Err() != nil {
return
}
s.sendSignal(s.signalDataArrived)
}
// addMetricsIfClients - will append a metric only if there are authenticated clients
// via which we can reach the endpoint.
func (s *Service) addMetricsIfClients(metric ...proton.ObservabilityMetric) {
hasClients := false
s.withUserClientStoreLock(func() {
hasClients = len(s.userClientStore) > 0
})
if !hasClients {
return
}
s.addMetrics(metric...)
}
func (s *Service) RegisterUserClient(userID string, protonClient *proton.Client, telemetryService *telemetry.Service) {
s.log.Info("Registering user client, ID:", userID)
@ -225,6 +260,8 @@ func (s *Service) RegisterUserClient(userID string, protonClient *proton.Client,
}
})
s.distinctionUtility.registerUserPlan(s.ctx, protonClient, s.panicHandler)
// There may be a case where we already have metric updates stored, so try to flush;
s.sendSignal(s.signalDataArrived)
}
@ -279,3 +316,25 @@ func (s *Service) sendSignal(channel chan struct{}) {
func ModifyThrottlePeriod(duration time.Duration) {
throttleDuration = duration
}
func (s *Service) AddMetrics(metrics ...proton.ObservabilityMetric) {
s.addMetrics(metrics...)
}
// AddDistinctMetrics - sends an additional metric related to the user, so we can determine
// what number of events come from what number of users.
// As the binning interval is what allows us to do this we
// should not send these if there are no logged-in users at that moment.
func (s *Service) AddDistinctMetrics(errType DistinctionErrorTypeEnum, metrics ...proton.ObservabilityMetric) {
metrics = s.distinctionUtility.generateDistinctMetrics(errType, metrics...)
s.addMetricsIfClients(metrics...)
}
// ModifyHeartbeatInterval - should only be used for testing. Resets the heartbeat ticker.
func (s *Service) ModifyHeartbeatInterval(duration time.Duration) {
s.distinctionUtility.heartbeatTicker.Reset(duration)
}
func ModifyUserMetricInterval(duration time.Duration) {
updateInterval = duration
}

View File

@ -0,0 +1,106 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"github.com/ProtonMail/go-proton-api"
)
func GenerateAllUsedDistinctionMetricPermutations() []proton.ObservabilityMetric {
planValues := []string{
planUnknown,
planOther,
planBusiness,
planIndividual,
planGroup}
mailClientValues := []string{
emailAgentAppleMail,
emailAgentOutlook,
emailAgentThunderbird,
emailAgentOther,
emailAgentUnknown,
}
enabledValues := []string{
getEnabled(true), getEnabled(false),
}
var metrics []proton.ObservabilityMetric
for _, schemaName := range errorSchemaMap {
for _, plan := range planValues {
for _, mailClient := range mailClientValues {
for _, dohEnabled := range enabledValues {
for _, betaAccess := range enabledValues {
metrics = append(metrics, generateUserMetric(schemaName, plan, mailClient, dohEnabled, betaAccess))
}
}
}
}
}
return metrics
}
func GenerateAllHeartbeatMetricPermutations() []proton.ObservabilityMetric {
planValues := []string{
planUnknown,
planOther,
planBusiness,
planIndividual,
planGroup}
mailClientValues := []string{
emailAgentAppleMail,
emailAgentOutlook,
emailAgentThunderbird,
emailAgentOther,
emailAgentUnknown,
}
enabledValues := []string{
getEnabled(true), getEnabled(false),
}
trueFalseValues := []string{
"true", "false",
}
var metrics []proton.ObservabilityMetric
for _, plan := range planValues {
for _, mailClient := range mailClientValues {
for _, dohEnabled := range enabledValues {
for _, betaAccess := range enabledValues {
for _, receivedOtherError := range trueFalseValues {
for _, receivedSyncError := range trueFalseValues {
for _, receivedEventLoopError := range trueFalseValues {
metrics = append(metrics,
generateHeartbeatMetric(plan,
mailClient,
dohEnabled,
betaAccess,
receivedOtherError,
receivedSyncError,
receivedEventLoopError,
),
)
}
}
}
}
}
}
}
return metrics
}

View File

@ -0,0 +1,68 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"strings"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
)
// settingsGetter - interface that maps to bridge object methods such that we
// can pass the whole object instead of individual function callbacks.
type settingsGetter interface {
GetCurrentUserAgent() string
GetProxyAllowed() bool
GetUpdateChannel() updater.Channel
}
// User agent mapping.
const (
emailAgentAppleMail = "apple_mail"
emailAgentOutlook = "outlook"
emailAgentThunderbird = "thunderbird"
emailAgentOther = "other"
emailAgentUnknown = "unknown"
)
func matchUserAgent(userAgent string) string {
if userAgent == "" {
return emailAgentUnknown
}
userAgent = strings.ToLower(userAgent)
switch {
case strings.Contains(userAgent, "outlook"):
return emailAgentOutlook
case strings.Contains(userAgent, "thunderbird"):
return emailAgentThunderbird
case strings.Contains(userAgent, "mac") && strings.Contains(userAgent, "mail"):
return emailAgentAppleMail
case strings.Contains(userAgent, "mac") && strings.Contains(userAgent, "notes"):
return emailAgentUnknown
default:
return emailAgentOther
}
}
func getEnabled(value bool) string {
if !value {
return "disabled"
}
return "enabled"
}

View File

@ -0,0 +1,111 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package observability
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMatchUserAgent(t *testing.T) {
type agentParseResult struct {
agent string
result string
}
testCases := []agentParseResult{
{
agent: "Microsoft Outlook/16.0.17928.20114 (Windows 11 Version 23H2)",
result: emailAgentOutlook,
},
{
agent: "Mailbird/3.0.18.0 (Windows 11 Version 23H2)",
result: emailAgentOther,
},
{
agent: "Microsoft Outlook/16.0.17830.20166 (Windows 11 Version 23H2)",
result: emailAgentOutlook,
},
{
agent: "Mac OS X Mail/16.0-3776.700.51 (macOS 14.6)",
result: emailAgentAppleMail,
},
{
agent: "/ (Windows 11 Version 23H2)",
result: emailAgentOther,
},
{
agent: "Microsoft Outlook for Mac/16.88.0-BUILDDAY (macOS 14.6)",
result: emailAgentOutlook,
},
{
agent: "/ (macOS 14.5)",
result: emailAgentOther,
},
{
agent: "/ (Freedesktop SDK 23.08 (Flatpak runtime))",
result: emailAgentOther,
},
{
agent: "Mac OS X Mail/16.0-3774.600.62 (macOS 14.5)",
result: emailAgentAppleMail,
},
{
agent: "Mac OS X Notes/4.11-2817 (macOS 14.6)",
result: emailAgentUnknown,
},
{
agent: "NoClient/0.0.1 (macOS 14.6)",
result: emailAgentOther,
},
{
agent: "Thunderbird/115.15.0 (Ubuntu 20.04.6 LTS)",
result: emailAgentThunderbird,
},
{
agent: "Thunderbird/115.14.0 (macOS 14.6)",
result: emailAgentThunderbird,
},
{
agent: "Thunderbird/115.10.2 (Windows 11 Version 23H2)",
result: emailAgentThunderbird,
},
{
agent: "Mac OS X Notes/4.9-1965 (macOS Monterey (12.0))",
result: emailAgentUnknown,
},
{
agent: " Thunderbird/115.14.0 (macOS 14.6) ",
result: emailAgentThunderbird,
},
{
agent: "",
result: emailAgentUnknown,
},
}
for _, testCase := range testCases {
require.Equal(t, testCase.result, matchUserAgent(testCase.agent))
}
}
func TestFormatBool(t *testing.T) {
require.Equal(t, "false", formatBool(false))
require.Equal(t, "true", formatBool(true))
}