diff --git a/go.mod b/go.mod index 87852eb1..84da421b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/semver/v3 v3.2.0 github.com/ProtonMail/gluon v0.17.1-0.20250627102828-b014b7cc8132 github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a - github.com/ProtonMail/go-proton-api v0.4.1-0.20250627135952-bf973947255c + github.com/ProtonMail/go-proton-api v0.4.1-0.20250717114555-7525f013ddc1 github.com/ProtonMail/gopenpgp/v2 v2.9.0-proton github.com/PuerkitoBio/goquery v1.8.1 github.com/abiosoft/ishell v2.0.0+incompatible diff --git a/go.sum b/go.sum index f8bb080e..7c219557 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,8 @@ github.com/ProtonMail/go-message v0.13.1-0.20240919135104-3bc88e6a9423 h1:p8nBDx github.com/ProtonMail/go-message v0.13.1-0.20240919135104-3bc88e6a9423/go.mod h1:NBAn21zgCJ/52WLDyed18YvYFm5tEoeDauubFqLokM4= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= -github.com/ProtonMail/go-proton-api v0.4.1-0.20250627135952-bf973947255c h1:FhfHIrGgehnTV/T2NkyVauKcJ3NzPq1uLcU/0eK661A= -github.com/ProtonMail/go-proton-api v0.4.1-0.20250627135952-bf973947255c/go.mod h1:9t9+oQfH+6ssa7O2nLv34Uyjv8UmqTPGbVNcFToewck= +github.com/ProtonMail/go-proton-api v0.4.1-0.20250717114555-7525f013ddc1 h1:Ryqa36leoJnZA1Ya4vfaH0rcTrrqjBuz9UG8HqCROrQ= +github.com/ProtonMail/go-proton-api v0.4.1-0.20250717114555-7525f013ddc1/go.mod h1:9t9+oQfH+6ssa7O2nLv34Uyjv8UmqTPGbVNcFToewck= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8= github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= diff --git a/internal/app/app.go b/internal/app/app.go index 952baee0..432bc55b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -41,6 +41,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/platform" "github.com/ProtonMail/proton-bridge/v3/internal/sentry" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/useragent" "github.com/ProtonMail/proton-bridge/v3/internal/vault" @@ -292,53 +293,56 @@ func run(c *cli.Context) error { return withSingleInstance(settings, locations.GetLockFile(), version, func() error { // Look for available keychains return WithKeychainList(crashHandler, func(keychains *keychain.List) error { - // Unlock the encrypted vault. - return WithVault(reporter, locations, keychains, featureFlags, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error { - if !v.Migrated() { - // Migrate old settings into the vault. - if err := migrateOldSettings(v); err != nil { - logrus.WithError(err).Error("Failed to migrate old settings") - } - - // Migrate old accounts into the vault. - if err := migrateOldAccounts(locations, keychains, v); err != nil { - logrus.WithError(err).Error("Failed to migrate old accounts") - } - - // The vault has been migrated. - if err := v.SetMigrated(); err != nil { - logrus.WithError(err).Error("Failed to mark vault as migrated") - } - } - - logrus.WithFields(logrus.Fields{ - "lastVersion": v.GetLastVersion().String(), - "showAllMail": v.GetShowAllMail(), - "updateCh": v.GetUpdateChannel(), - "autoUpdate": v.GetAutoUpdate(), - "rollout": v.GetUpdateRollout(), - "DoH": v.GetProxyAllowed(), - }).Info("Vault loaded") - - // Load the cookies from the vault. - return withCookieJar(v, func(cookieJar http.CookieJar) error { - // Create a new bridge instance. - return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, keychains, func(b *bridge.Bridge, eventCh <-chan events.Event) error { - if insecure { - logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted") - b.PushError(bridge.ErrVaultInsecure) + // Pre-init the observability service, load the cached metrics. + return observability.WithObservability(locations, func(obsService *observability.Service) error { + // Unlock the encrypted vault. + return WithVault(reporter, locations, keychains, obsService, featureFlags, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error { + if !v.Migrated() { + // Migrate old settings into the vault. + if err := migrateOldSettings(v); err != nil { + logrus.WithError(err).Error("Failed to migrate old settings") } - if corrupt { - logrus.Warn("The vault is corrupt and has been wiped") - b.PushError(bridge.ErrVaultCorrupt) + // Migrate old accounts into the vault. + if err := migrateOldAccounts(locations, keychains, v); err != nil { + logrus.WithError(err).Error("Failed to migrate old accounts") } - // Remove old updates files - b.RemoveOldUpdates() + // The vault has been migrated. + if err := v.SetMigrated(); err != nil { + logrus.WithError(err).Error("Failed to mark vault as migrated") + } + } - // Run the frontend. - return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID)) + logrus.WithFields(logrus.Fields{ + "lastVersion": v.GetLastVersion().String(), + "showAllMail": v.GetShowAllMail(), + "updateCh": v.GetUpdateChannel(), + "autoUpdate": v.GetAutoUpdate(), + "rollout": v.GetUpdateRollout(), + "DoH": v.GetProxyAllowed(), + }).Info("Vault loaded") + + // Load the cookies from the vault. + return withCookieJar(v, func(cookieJar http.CookieJar) error { + // Create a new bridge instance. + return withBridge(c, exe, locations, version, identifier, obsService, crashHandler, reporter, v, cookieJar, keychains, func(b *bridge.Bridge, eventCh <-chan events.Event) error { + if insecure { + logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted") + b.PushError(bridge.ErrVaultInsecure) + } + + if corrupt { + logrus.Warn("The vault is corrupt and has been wiped") + b.PushError(bridge.ErrVaultCorrupt) + } + + // Remove old updates files + b.RemoveOldUpdates() + + // Run the frontend. + return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID)) + }) }) }) }) diff --git a/internal/app/bridge.go b/internal/app/bridge.go index 017cc354..2b56158f 100644 --- a/internal/app/bridge.go +++ b/internal/app/bridge.go @@ -33,6 +33,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/sentry" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/useragent" "github.com/ProtonMail/proton-bridge/v3/internal/vault" @@ -52,6 +53,7 @@ func withBridge( locations *locations.Locations, version *semver.Version, identifier *useragent.UserAgent, + obsService *observability.Service, crashHandler *crash.Handler, reporter *sentry.Reporter, vault *vault.Vault, @@ -100,6 +102,7 @@ func withBridge( updater, version, keychains, + obsService, // The API stuff. constants.APIHost, diff --git a/internal/app/vault.go b/internal/app/vault.go index f8e7d7a4..c0c91030 100644 --- a/internal/app/vault.go +++ b/internal/app/vault.go @@ -31,18 +31,20 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/platform" "github.com/ProtonMail/proton-bridge/v3/internal/sentry" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/vault" + "github.com/ProtonMail/proton-bridge/v3/internal/vault/observabilitymetrics" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/sirupsen/logrus" ) -func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error { +func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, obsSender observability.BasicSender, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler, fn func(*vault.Vault, bool, bool) error) error { logrus.Debug("Creating vault") defer logrus.Debug("Vault stopped") // Create the encVault. - encVault, insecure, corrupt, err := newVault(reporter, locations, keychains, featureFlags, panicHandler) + encVault, insecure, corrupt, err := newVault(reporter, locations, keychains, obsSender, featureFlags, panicHandler) if err != nil { return fmt.Errorf("could not create vault: %w", err) } @@ -64,7 +66,7 @@ func WithVault(reporter *sentry.Reporter, locations *locations.Locations, keycha return fn(encVault, insecure, corrupt != nil) } -func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler) (*vault.Vault, bool, error, error) { +func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychains *keychain.List, obsSender observability.BasicSender, featureFlags unleash.FeatureFlagStartupStore, panicHandler async.PanicHandler) (*vault.Vault, bool, error, error) { vaultDir, err := locations.ProvideSettingsPath() if err != nil { return nil, false, nil, fmt.Errorf("could not get vault dir: %w", err) @@ -101,6 +103,9 @@ func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychai // We store the insecure vault in a separate directory vaultDir = path.Join(vaultDir, "insecure") + + // Schedule the relevant observability metric for sending. + obsSender.AddMetrics(observabilitymetrics.GenerateVaultKeyFetchGenericErrorMetric()) } else { vaultKey = key lastUsedHelper = helper @@ -114,9 +119,14 @@ func newVault(reporter *sentry.Reporter, locations *locations.Locations, keychai userVault, corrupt, err := vault.New(vaultDir, gluonCacheDir, vaultKey, panicHandler) if err != nil { + obsSender.AddMetrics(observabilitymetrics.GenerateVaultCreationGenericErrorMetric()) return nil, false, corrupt, fmt.Errorf("could not create vault: %w", err) } + if corrupt != nil { + obsSender.AddMetrics(observabilitymetrics.GenerateVaultCreationCorruptErrorMetric()) + } + // Remember the last successfully used keychain on Linux and store that as the user preference. if runtime.GOOS == platform.LINUX { if err := vault.SetHelper(vaultDir, lastUsedHelper); err != nil { diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index fa30a9ea..4b371bfe 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -168,6 +168,7 @@ func New( updater Updater, // the updater to fetch and install updates curVersion *semver.Version, // the current version of the bridge keychains *keychain.List, // usable keychains + obsService *observability.Service, apiURL string, // the URL of the API to use cookieJar http.CookieJar, // the cookie jar to use @@ -206,6 +207,7 @@ func New( keychains, panicHandler, reporter, + obsService, api, identifier, @@ -242,6 +244,7 @@ func newBridge( keychains *keychain.List, panicHandler async.PanicHandler, reporter reporter.Reporter, + obsService *observability.Service, api *proton.Manager, identifier identifier.Identifier, @@ -275,7 +278,7 @@ func newBridge( unleashService := unleash.NewBridgeService(ctx, api, locator, panicHandler, vault.GetFeatureFlagStickyKey()) - observabilityService := observability.NewService(ctx, panicHandler) + obsService.Initialize(ctx, panicHandler) bridge := &Bridge{ vault: vault, @@ -317,11 +320,11 @@ func newBridge( lastVersion: lastVersion, tasks: tasks, - syncService: syncservice.NewService(panicHandler, observabilityService), + syncService: syncservice.NewService(panicHandler, obsService), unleashService: unleashService, - observabilityService: observabilityService, + observabilityService: obsService, notificationStore: notifications.NewStore(locator.ProvideNotificationsCachePath), @@ -336,7 +339,7 @@ func newBridge( reporter, uidValidityGenerator, &bridgeIMAPSMTPTelemetry{b: bridge}, - observabilityService, + obsService, unleashService, ) diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 5fd9e78a..4d787b08 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -45,6 +45,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/focus" "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/services/imapsmtpserver" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/user" @@ -944,6 +945,7 @@ func withBridgeNoMocks( mocks.Updater, v2_3_0, keychain.NewTestKeychainsList(), + observability.NewTestService(), // The API stuff. apiURL, diff --git a/internal/dialer/dialer_pinning_test.go b/internal/dialer/dialer_pinning_test.go index b8f8f9ab..c8309c0c 100644 --- a/internal/dialer/dialer_pinning_test.go +++ b/internal/dialer/dialer_pinning_test.go @@ -95,7 +95,7 @@ func TestTLSSignedCertTrustedPublicKey(t *testing.T) { _, dialer, _, checker, _ := createClientWithPinningDialer("") copyTrustedPins(checker) - checker.trustedPins = append(checker.trustedPins, `pin-sha256="FlvTPG/nIMKtOj9nelnEjujwSZ5EDyfiKYxZgbXREls="`) + checker.trustedPins = append(checker.trustedPins, `pin-sha256="coQ/Z6I+kjMViHVis67UVQDyCzXa1IVEuTKwF8wR9uQ="`) _, err := dialer.DialTLSContext(context.Background(), "tcp", "rsa4096.badssl.com:443") r.NoError(t, err, "expected dial to succeed because public key is known and cert is signed by CA") } diff --git a/internal/locations/locations.go b/internal/locations/locations.go index a3876058..853081f7 100644 --- a/internal/locations/locations.go +++ b/internal/locations/locations.go @@ -215,6 +215,14 @@ func (l *Locations) ProvideUnleashStartupCachePath() (string, error) { return l.getUnleashStartupCachePath(), nil } +func (l *Locations) ProvideObservabilityMetricsCachePath() (string, error) { + if err := os.MkdirAll(l.getObservabilityMetricsCachePath(), 0o700); err != nil { + return "", err + } + + return l.getObservabilityMetricsCachePath(), nil +} + func (l *Locations) getGluonCachePath() string { return filepath.Join(l.userData, "gluon") } @@ -257,6 +265,10 @@ func (l *Locations) getUnleashStartupCachePath() string { return filepath.Join(l.userCache, "unleash_startup_cache") } +func (l *Locations) getObservabilityMetricsCachePath() string { + return filepath.Join(l.userCache, "observability_cache") +} + // Clear removes everything except the lock and update files. func (l *Locations) Clear(except ...string) error { return files.Remove( diff --git a/internal/services/observability/service.go b/internal/services/observability/service.go index b56cdb62..8aede912 100644 --- a/internal/services/observability/service.go +++ b/internal/services/observability/service.go @@ -19,12 +19,17 @@ package observability import ( "context" + "encoding/json" + "os" + "path/filepath" "sync" "time" "github.com/ProtonMail/gluon/async" "github.com/ProtonMail/go-proton-api" + "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/services/telemetry" + "github.com/bradenaw/juniper/xslices" "github.com/sirupsen/logrus" ) @@ -34,6 +39,7 @@ var throttleDuration = 5 * time.Second //nolint:gochecknoglobals const ( maxStorageSize = 5000 maxBatchSize = 1000 + filename = "metric_cache.json" ) type client struct { @@ -50,15 +56,23 @@ type Sender interface { GetEmailClient() string } +type BasicSender interface { + AddMetrics(metric ...proton.ObservabilityMetric) +} + type Service struct { ctx context.Context cancel context.CancelFunc panicHandler async.PanicHandler + cachePath string + lastDispatch time.Time isDispatchScheduled bool + wg sync.WaitGroup + signalDataArrived chan struct{} signalDispatch chan struct{} @@ -73,41 +87,70 @@ type Service struct { distinctionUtility *distinctionUtility } -func NewService(ctx context.Context, panicHandler async.PanicHandler) *Service { - ctx, cancel := context.WithCancel(ctx) - - service := &Service{ - ctx: ctx, - cancel: cancel, - - panicHandler: panicHandler, - - lastDispatch: time.Now().Add(-throttleDuration), - - signalDataArrived: make(chan struct{}, 1), - signalDispatch: make(chan struct{}, 1), - - log: logrus.WithFields(logrus.Fields{"pkg": "observability"}), - - metricStore: make([]proton.ObservabilityMetric, 0), - +func newService() *Service { + return &Service{ + ctx: context.Background(), + metricStore: make([]proton.ObservabilityMetric, 0), + log: logrus.WithFields(logrus.Fields{"pkg": "observability"}), userClientStore: make(map[string]*client), } +} - service.distinctionUtility = newDistinctionUtility(ctx, panicHandler, service) +// NewTestService initializes a new basic observability service with the required struct fields. +// Should only be used for testing. +func NewTestService() *Service { + return newService() +} - return service +func WithObservability(locations *locations.Locations, fn func(service *Service) error) error { + service := newService() + + cacheDir, err := locations.ProvideObservabilityMetricsCachePath() + if err != nil { + service.log.WithError(err).Warn("Could not obtain cache path") + return fn(service) + } + + cachePath := filepath.Clean(filepath.Join(cacheDir, filename)) + service.cachePath = cachePath + + service.readCacheFile() + + defer service.writeCacheFile() + + return fn(service) +} + +// Initialize sets up the observability Service. If not initialized, the service will remain inactive and emit no metrics. +// Should exclusively be called during bridge set-up. +func (s *Service) Initialize(ctx context.Context, panicHandler async.PanicHandler) { + ctx, cancel := context.WithCancel(ctx) + + s.ctx = ctx + s.cancel = cancel + s.panicHandler = panicHandler + + s.lastDispatch = time.Now().Add(-throttleDuration) + + s.signalDataArrived = make(chan struct{}, 1) + s.signalDispatch = make(chan struct{}, 1) + + s.distinctionUtility = newDistinctionUtility(ctx, panicHandler, s) } // 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") + if s.log != nil { + s.log.Info("Starting service") + } s.distinctionUtility.setSettingsGetter(settingsGetter) s.distinctionUtility.runHeartbeat() + s.wg.Add(1) go func() { + defer s.wg.Done() s.start() }() } @@ -142,6 +185,62 @@ func (s *Service) start() { } } +func (s *Service) readCacheFile() { + if s.cachePath == "" { + return + } + + file, err := os.Open(s.cachePath) + if err != nil { + s.log.WithError(err).Info("Unable to open cache file") + return + } + + defer func(file *os.File) { + if err := file.Close(); err != nil { + s.log.WithError(err).Error("Unable to close cache file after read") + } + }(file) + + s.withMetricStoreLock(func() { + if err = json.NewDecoder(file).Decode(&s.metricStore); err != nil { + s.log.WithError(err).Error("Unable to decode cache file") + } + + // Since we omit marshalling the field, we need to explicitly overwrite it. + for i := range s.metricStore { + s.metricStore[i].ShouldCache = true + } + }) +} + +func (s *Service) writeCacheFile() { + if s.cachePath == "" { + return + } + + file, err := os.Create(s.cachePath) + if err != nil { + s.log.WithError(err).Warn("Unable to create cache file") + } + + defer func(file *os.File) { + if err := file.Close(); err != nil { + s.log.WithError(err).Error("Unable to close cache file after write") + } + }(file) + + s.withMetricStoreLock(func() { + metricsToCache := xslices.Filter(s.metricStore, func(m proton.ObservabilityMetric) bool { + return m.ShouldCache + }) + + if err = json.NewEncoder(file).Encode(metricsToCache); err != nil { + s.log.WithError(err).Error("Unable to encode data to cache file") + } + }) +} + func (s *Service) dispatchData() { s.isDispatchScheduled = false // Only accessed via a single goroutine, so no mutexes. if !s.haveMetricsAndClients() { @@ -237,6 +336,12 @@ func (s *Service) addMetrics(metric ...proton.ObservabilityMetric) { s.sendSignal(s.signalDataArrived) } +func (s *Service) flushMetricsTest() { + s.withMetricStoreLock(func() { + s.metricStore = make([]proton.ObservabilityMetric, 0) + }) +} + // 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) { @@ -280,6 +385,7 @@ func (s *Service) Stop() { s.log.Info("Stopping service") s.cancel() + s.wg.Wait() close(s.signalDataArrived) close(s.signalDispatch) } diff --git a/internal/services/observability/service_test.go b/internal/services/observability/service_test.go new file mode 100644 index 00000000..7affdef3 --- /dev/null +++ b/internal/services/observability/service_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2025 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 . + +package observability + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/ProtonMail/go-proton-api" + "github.com/stretchr/testify/require" +) + +func TestService_cacheFile_NoCachePath(t *testing.T) { + s := NewTestService() + s.readCacheFile() + s.writeCacheFile() + require.Empty(t, s.metricStore) +} + +func TestService_cacheFile_ValidCachePath(t *testing.T) { + tempDir := t.TempDir() + cachePath := filepath.Join(tempDir, "test_cache.json") + + s := NewTestService() + s.cachePath = cachePath + + s.readCacheFile() + s.writeCacheFile() + require.Empty(t, s.metricStore) +} + +func TestService_cacheFile_AllMetricsCacheable(t *testing.T) { + tempDir := t.TempDir() + cachePath := filepath.Clean(filepath.Join(tempDir, "test_cache.json")) + + s := NewTestService() + s.cachePath = cachePath + s.ctx = context.Background() + + testMetrics := []proton.ObservabilityMetric{ + { + Name: "test1", + Version: 1, + Timestamp: time.Now().Unix(), + Data: nil, + ShouldCache: true, + }, + { + + Name: "test2", + Version: 2, + Timestamp: time.Now().Unix(), + Data: nil, + ShouldCache: true, + }, + { + + Name: "test3", + Version: 3, + Timestamp: time.Now().Unix(), + Data: nil, + ShouldCache: true, + }, + } + + s.readCacheFile() + require.Empty(t, s.metricStore) + + s.addMetrics(testMetrics...) + require.Equal(t, s.metricStore, testMetrics) + + s.writeCacheFile() + s.flushMetricsTest() + require.Empty(t, s.metricStore) + + s.readCacheFile() + require.Equal(t, s.metricStore, testMetrics) +} + +func TestService_cacheFile_SomeMetricsCacheable(t *testing.T) { + tempDir := t.TempDir() + cachePath := filepath.Clean(filepath.Join(tempDir, "test_cache.json")) + + s := NewTestService() + s.cachePath = cachePath + s.ctx = context.Background() + + testMetricsCacheable := []proton.ObservabilityMetric{ + { + Name: "test1", + Version: 1, + Timestamp: time.Now().Unix(), + Data: nil, + ShouldCache: true, + }, + { + + Name: "test2", + Version: 2, + Timestamp: time.Now().Unix(), + Data: nil, + ShouldCache: true, + }, + } + + testMetricsNonCacheable := []proton.ObservabilityMetric{ + { + Name: "test3", + Version: 3, + Timestamp: time.Now().Unix(), + }, + { + + Name: "test2", + Version: 2, + Timestamp: time.Now().Unix(), + }, + } + + s.readCacheFile() + require.Empty(t, s.metricStore) + + s.addMetrics(testMetricsCacheable...) + s.addMetrics(testMetricsNonCacheable...) + + require.Equal(t, s.metricStore, append(testMetricsCacheable, testMetricsNonCacheable...)) + + s.writeCacheFile() + s.flushMetricsTest() + require.Empty(t, s.metricStore) + + s.readCacheFile() + require.Equal(t, s.metricStore, testMetricsCacheable) +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 9233f45f..77901113 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -168,7 +168,7 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma nullSMTPServerManager, nullEventSubscription, nil, - observability.NewService(context.Background(), nil), + observability.NewTestService(), "", true, notifications.NewStore(func() (string, error) { diff --git a/internal/vault/observabilitymetrics/metrics.go b/internal/vault/observabilitymetrics/metrics.go new file mode 100644 index 00000000..5e81516f --- /dev/null +++ b/internal/vault/observabilitymetrics/metrics.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025 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 . + +package observabilitymetrics + +import ( + "time" + + "github.com/ProtonMail/go-proton-api" +) + +const ( + vaultErrorsSchemaName = "bridge_vault_errors_total" + vaultErrorsSchemaVersion = 1 +) + +func generateVaultErrorObservabilityMetric(errorType string) proton.ObservabilityMetric { + return proton.ObservabilityMetric{ + Name: vaultErrorsSchemaName, + Version: vaultErrorsSchemaVersion, + Timestamp: time.Now().Unix(), + ShouldCache: true, + Data: map[string]interface{}{ + "Value": 1, + "Labels": map[string]string{ + "errorType": errorType, + }, + }, + } +} + +func GenerateVaultCreationCorruptErrorMetric() proton.ObservabilityMetric { + return generateVaultErrorObservabilityMetric("vaultCorrupt") +} + +func GenerateVaultCreationGenericErrorMetric() proton.ObservabilityMetric { + return generateVaultErrorObservabilityMetric("vaultError") +} + +func GenerateVaultKeyFetchGenericErrorMetric() proton.ObservabilityMetric { + return generateVaultErrorObservabilityMetric("keychainError") +} diff --git a/tests/ctx_bridge_test.go b/tests/ctx_bridge_test.go index c33ea22c..b0752c41 100644 --- a/tests/ctx_bridge_test.go +++ b/tests/ctx_bridge_test.go @@ -39,6 +39,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/events" frontend "github.com/ProtonMail/proton-bridge/v3/internal/frontend/grpc" "github.com/ProtonMail/proton-bridge/v3/internal/service" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/useragent" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" @@ -172,6 +173,7 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) { t.mocks.Updater, t.version, keychain.NewTestKeychainsList(), + observability.NewTestService(), // API stuff t.api.GetHostURL(), diff --git a/tests/features/observability/all_metrics.feature b/tests/features/observability/all_metrics.feature index cdce3fea..a665ac02 100644 --- a/tests/features/observability/all_metrics.feature +++ b/tests/features/observability/all_metrics.feature @@ -50,4 +50,8 @@ Feature: Bridge send remote notification observability metrics And the user with username "[user:user1]" sends an SMTP send request observability metric Then it succeeds + Scenario: Test all possible Vault/Keychain related error observability metrics + When the user logs in with username "[user:user1]" and password "password" + And the user with username "[user:user1]" sends all possible vault or keychain related error observability metrics + Then it succeeds diff --git a/tests/observability_test.go b/tests/observability_test.go index 50ab5ad2..ec4c95b6 100644 --- a/tests/observability_test.go +++ b/tests/observability_test.go @@ -28,6 +28,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/services/observability/gluonmetrics" smtpMetrics "github.com/ProtonMail/proton-bridge/v3/internal/services/smtp/observabilitymetrics" "github.com/ProtonMail/proton-bridge/v3/internal/services/syncservice/observabilitymetrics" + vaultMetrics "github.com/ProtonMail/proton-bridge/v3/internal/vault/observabilitymetrics" ) // userHeartbeatPermutationsObservability corresponds to bridge_generic_user_heartbeat_total_v1.schema.json. @@ -234,3 +235,18 @@ func (s *scenario) GluonNewlyOpenedIMAPConnectionsExceedThreshold(username strin return err }) } + +func (s *scenario) VaultKeychainErrorsObservabilityMetrics(username string) error { + batch := proton.ObservabilityBatch{ + Metrics: []proton.ObservabilityMetric{ + vaultMetrics.GenerateVaultKeyFetchGenericErrorMetric(), + vaultMetrics.GenerateVaultCreationCorruptErrorMetric(), + vaultMetrics.GenerateVaultCreationGenericErrorMetric(), + }, + } + + return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error { + err := c.SendObservabilityBatch(ctx, batch) + return err + }) +} diff --git a/tests/steps_test.go b/tests/steps_test.go index dfcbcec6..5e445045 100644 --- a/tests/steps_test.go +++ b/tests/steps_test.go @@ -244,6 +244,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) { ctx.Step(`^the user with username "([^"]*)" sends SMTP send success observability metric$`, s.SMTPSendSuccessObservabilityMetric) // SMTP submission metric ctx.Step(`^the user with username "([^"]*)" sends an SMTP send request observability metric$`, s.SMTPSendRequestObservabilityMetric) + // Vault/Keychain related error metrics. + ctx.Step(`^the user with username "([^"]*)" sends all possible vault or keychain related error observability metrics$`, s.VaultKeychainErrorsObservabilityMetrics) // Gluon related metrics ctx.Step(`^the user with username "([^"]*)" sends all possible gluon error observability metrics$`, s.testGluonErrorObservabilityMetrics) diff --git a/utils/bridge-rollout/bridge-rollout.go b/utils/bridge-rollout/bridge-rollout.go index 156e2e06..e601d177 100644 --- a/utils/bridge-rollout/bridge-rollout.go +++ b/utils/bridge-rollout/bridge-rollout.go @@ -24,6 +24,7 @@ import ( "github.com/ProtonMail/gluon/async" "github.com/ProtonMail/proton-bridge/v3/internal/app" "github.com/ProtonMail/proton-bridge/v3/internal/locations" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/sirupsen/logrus" @@ -62,7 +63,7 @@ func main() { func getRollout(_ *cli.Context) error { return app.WithLocations(func(locations *locations.Locations) error { return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error { - return app.WithVault(nil, locations, keychains, make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error { + return app.WithVault(nil, locations, keychains, observability.NewTestService(), make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error { fmt.Println(vault.GetUpdateRollout()) return nil }) @@ -73,7 +74,7 @@ func getRollout(_ *cli.Context) error { func setRollout(c *cli.Context) error { return app.WithLocations(func(locations *locations.Locations) error { return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error { - return app.WithVault(nil, locations, keychains, make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error { + return app.WithVault(nil, locations, keychains, observability.NewTestService(), make(map[string]bool), async.NoopPanicHandler{}, func(vault *vault.Vault, _, _ bool) error { clamped := max(0.0, min(1.0, c.Float64("value"))) if err := vault.SetUpdateRollout(clamped); err != nil { return err diff --git a/utils/vault-editor/main.go b/utils/vault-editor/main.go index 90f05c14..fca71259 100644 --- a/utils/vault-editor/main.go +++ b/utils/vault-editor/main.go @@ -27,6 +27,7 @@ import ( "github.com/ProtonMail/gluon/async" "github.com/ProtonMail/proton-bridge/v3/internal/app" "github.com/ProtonMail/proton-bridge/v3/internal/locations" + "github.com/ProtonMail/proton-bridge/v3/internal/services/observability" "github.com/ProtonMail/proton-bridge/v3/internal/unleash" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" @@ -53,7 +54,7 @@ func main() { func readAction(c *cli.Context) error { return app.WithLocations(func(locations *locations.Locations) error { return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error { - return app.WithVault(nil, locations, keychains, make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error { + return app.WithVault(nil, locations, keychains, observability.NewTestService(), make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error { if _, err := os.Stdout.Write(vault.ExportJSON()); err != nil { return fmt.Errorf("failed to write vault: %w", err) } @@ -67,7 +68,7 @@ func readAction(c *cli.Context) error { func writeAction(c *cli.Context) error { return app.WithLocations(func(locations *locations.Locations) error { return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error { - return app.WithVault(nil, locations, keychains, make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error { + return app.WithVault(nil, locations, keychains, observability.NewTestService(), make(unleash.FeatureFlagStartupStore), async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error { b, err := io.ReadAll(os.Stdin) if err != nil { return fmt.Errorf("failed to read vault: %w", err)