From b574871299b9cda844a7240fddae32dbd76ad24d Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Sun, 15 Jun 2025 02:48:22 +0300 Subject: [PATCH 1/8] feat(report): enhance Reporter with flexible error reporting options - Added `SentryReportOptions` struct to allow passing extra context, custom tags, and severity level. - Introduced `ReportErrorWithSentryOptions` method for reporting with fine-grained control. - Improved `ConfigureScope` by including `goarch` and renaming `version` tag to `app_version`. - Renamed `ReportIfProd` to `ReportError` and removed environment check to allow caller control. --- internal/report/reporter.go | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/internal/report/reporter.go b/internal/report/reporter.go index 339d29c..92aefa7 100644 --- a/internal/report/reporter.go +++ b/internal/report/reporter.go @@ -7,11 +7,15 @@ import ( "github.com/getsentry/sentry-go" ) + +// Reporter provides methods to configure Sentry and report errors +// with environment-specific metadata (like env, version, arch, etc.). type Reporter struct { - Env string - Version string + Env string // The environment name (e.g., "development", "production", "staging") + Version string // The application version } +// NewReporter constructs a new Reporter with the given environment and version. func NewReporter(env, version string) *Reporter { return &Reporter{ Env: env, @@ -23,14 +27,17 @@ func NewReporter(env, version string) *Reporter { func (r *Reporter) ConfigureScope() { sentry.ConfigureScope(func(scope *sentry.Scope) { scope.SetTag("env", r.Env) - scope.SetTag("version", r.Version) + scope.SetTag("app_version", r.Version) scope.SetTag("go_version", runtime.Version()) + scope.SetTag("goarch", runtime.GOARCH) scope.SetContext("host_info", map[string]interface{}{ "hostname": r.getHostname(), }) }) } +// getHostname retrieves the system hostname. +// If the hostname cannot be determined, it returns "unknown". func (r *Reporter) getHostname() string { hostname, err := os.Hostname() if err != nil { @@ -40,11 +47,10 @@ func (r *Reporter) getHostname() string { } -// ReportIfProd sends the error to Sentry only if the environment is production. -// Optionally accepts a Sentry severity level (defaults to sentry.LevelError). - -func (r *Reporter) ReportIfProd(err error, extraContext map[string]interface{}, levels ...sentry.Level) { - if err == nil || r.Env != "production" { +// ReportError reports the error to Sentry with the given severity level +// If no level is provided, it defaults to sentry.LevelError. +func (r *Reporter) ReportError(err error, levels ...sentry.Level) { + if err == nil { return } @@ -54,10 +60,37 @@ func (r *Reporter) ReportIfProd(err error, extraContext map[string]interface{}, } sentry.WithScope(func(scope *sentry.Scope) { - if extraContext != nil { - scope.SetContext("extra", extraContext) - } scope.SetLevel(level) sentry.CaptureException(err) }) } + + +// SentryReportOptions provides optional data for reporting. +type SentryReportOptions struct { + ExtraContext map[string]interface{} + Tags map[string]string + Level sentry.Level +} + +// ReportErrorWithSentryOptions reports the error with additional options (tags, context, level). +func (r *Reporter) ReportErrorWithSentryOptions(err error, opts SentryReportOptions) { + if err == nil { + return + } + + sentry.WithScope(func(scope *sentry.Scope) { + if opts.ExtraContext != nil { + scope.SetContext("extra", opts.ExtraContext) + } + if opts.Tags != nil { + for k, v := range opts.Tags { + scope.SetTag(k, v) + } + } + if opts.Level != "" { + scope.SetLevel(opts.Level) + } + sentry.CaptureException(err) + }) +} From cde5e37f24e0b99510703b3ff4a267e1335b97d9 Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Sun, 15 Jun 2025 02:52:34 +0300 Subject: [PATCH 2/8] feat: integrate Sentry reporting and improve error handling in main application flow - Add Reporter dependency to application struct and main functions - Report errors to Sentry with tags and context across GTFS download, config loading, and cache directory creation - Refactor main.go to pass Reporter to functions that can produce reportable errors --- cmd/watchdog/main.go | 122 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 25 deletions(-) diff --git a/cmd/watchdog/main.go b/cmd/watchdog/main.go index 4a223b1..fe31601 100644 --- a/cmd/watchdog/main.go +++ b/cmd/watchdog/main.go @@ -33,6 +33,7 @@ const version = "1.0.0" type application struct { config server.Config logger *slog.Logger + reporter *report.Reporter mu sync.RWMutex } @@ -60,13 +61,19 @@ func main() { os.Exit(1) } + report.SetupSentry() + defer report.FlushSentry() + + reporter := report.NewReporter(cfg.Env, version) + reporter.ConfigureScope() + var servers []models.ObaServer if *configFile != "" { - servers, err = loadConfigFromFile(*configFile) + servers, err = loadConfigFromFile(*configFile , reporter) } else if *configURL != "" { - servers, err = loadConfigFromURL(*configURL, configAuthUser, configAuthPass) + servers, err = loadConfigFromURL(*configURL, configAuthUser, configAuthPass , reporter) } else { fmt.Println("Error: No configuration provided. Use --config-file or --config-url.") flag.Usage() @@ -87,35 +94,31 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - report.SetupSentry() - defer report.FlushSentry() - - reporter := report.NewReporter(cfg.Env, version) - reporter.ConfigureScope() cacheDir := "cache" - if err = createCacheDirectory(cacheDir, logger); err != nil { + if err = createCacheDirectory(cacheDir, logger , reporter); err != nil { logger.Error("Failed to create cache directory", "error", err) os.Exit(1) } // Download GTFS bundles for all servers on startup - downloadGTFSBundles(servers, cacheDir, logger) + downloadGTFSBundles(servers, cacheDir, logger , reporter) app := &application{ config: cfg, logger: logger, + reporter: reporter, } app.startMetricsCollection() // Cron job to download GTFS bundles for all servers every 24 hours - go refreshGTFSBundles(servers, cacheDir, logger , 24 * time.Hour) + go refreshGTFSBundles(servers, cacheDir, logger , 24 * time.Hour , reporter) // If a remote URL is specified, refresh the configuration every minute if *configURL != "" { - go refreshConfig(*configURL, configAuthUser, configAuthPass, app, logger, time.Minute) + go refreshConfig(*configURL, configAuthUser, configAuthPass, app, logger, time.Minute , reporter) } srv := &http.Server{ @@ -129,10 +132,8 @@ func main() { logger.Info("starting server", "addr", srv.Addr, "env", cfg.Env) err = srv.ListenAndServe() - reporter.ReportIfProd(err, map[string]interface{}{ - "addr": srv.Addr, - "env": cfg.Env, - }, sentry.LevelFatal) + reporter.ReportError(err, sentry.LevelFatal) + report.FlushSentry() logger.Error(err.Error()) os.Exit(1) } @@ -147,12 +148,18 @@ func validateConfigFlags(configFile, configURL *string) error{ // createCacheDirectory ensures the cache directory exists, creating it if necessary. -func createCacheDirectory(cacheDir string , logger *slog.Logger) error{ +func createCacheDirectory(cacheDir string , logger *slog.Logger , reporter *report.Reporter) error{ stat, err := os.Stat(cacheDir); if err != nil { if os.IsNotExist(err){ if err := os.MkdirAll(cacheDir, os.ModePerm); err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Level: sentry.LevelError, + ExtraContext: map[string]interface{}{ + "cache_dir": cacheDir, + }, + }) return err } return nil @@ -161,13 +168,20 @@ func createCacheDirectory(cacheDir string , logger *slog.Logger) error{ } if !stat.IsDir() { - return fmt.Errorf("%s is not a directory", cacheDir) + err := fmt.Errorf("%s is not a directory", cacheDir) + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Level: sentry.LevelError, + ExtraContext: map[string]interface{}{ + "cache_dir": cacheDir, + }, + }) + return err } return nil } // downloadGTFSBundles downloads GTFS bundles for each server and caches them locally. -func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger) { +func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger , reporter *report.Reporter) { for _, server := range servers { hash := sha1.Sum([]byte(server.GtfsUrl)) hashStr := hex.EncodeToString(hash[:]) @@ -175,6 +189,15 @@ func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *sl _, err := utils.DownloadGTFSBundle(server.GtfsUrl, cacheDir, server.ID, hashStr) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + }, + ExtraContext: map[string]interface{}{ + "gtfs_url": server.GtfsUrl, + }, + Level: sentry.LevelError, + }) logger.Error("Failed to download GTFS bundle", "server_id", server.ID, "error", err) } else { logger.Info("Successfully downloaded GTFS bundle", "server_id", server.ID, "path", cachePath) @@ -183,19 +206,25 @@ func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *sl } // refreshGTFSBundles periodically downloads GTFS bundles at the specified interval. -func refreshGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger , interval time.Duration) { +func refreshGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger , interval time.Duration , reporter *report.Reporter) { for { time.Sleep(interval) - downloadGTFSBundles(servers, cacheDir, logger) + downloadGTFSBundles(servers, cacheDir, logger , reporter) } } // refreshConfig periodically fetches remote config and updates the application servers. -func refreshConfig(configURL, configAuthUser, configAuthPass string, app *application, logger *slog.Logger , interval time.Duration) { +func refreshConfig(configURL, configAuthUser, configAuthPass string, app *application, logger *slog.Logger , interval time.Duration , reporter *report.Reporter) { for { time.Sleep(interval) - newServers, err := loadConfigFromURL(configURL, configAuthUser, configAuthPass) + newServers, err := loadConfigFromURL(configURL, configAuthUser, configAuthPass , reporter) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": configURL, + }, + Level: sentry.LevelError, + }) logger.Error("Failed to refresh remote config", "error", err) continue } @@ -213,24 +242,42 @@ func (app *application) updateConfig(newServers []models.ObaServer) { } -func loadConfigFromFile(filePath string) ([]models.ObaServer, error) { +func loadConfigFromFile(filePath string , reporter *report.Reporter) ([]models.ObaServer, error) { data, err := os.ReadFile(filePath) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "file_path": filePath, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to read config file: %v", err) } var servers []models.ObaServer if err := json.Unmarshal(data, &servers); err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "file_path": filePath, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) } return servers, nil } -func loadConfigFromURL(url, authUser, authPass string) ([]models.ObaServer, error) { +func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporter) ([]models.ObaServer, error) { client := &http.Client{} req, err := http.NewRequest("GET", url, nil) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": url, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to create request: %v", err) } @@ -240,21 +287,46 @@ func loadConfigFromURL(url, authUser, authPass string) ([]models.ObaServer, erro resp, err := client.Do(req) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": url, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to fetch remote config: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("remote config returned status: %d", resp.StatusCode) + statusErr := fmt.Errorf("remote config returned status: %d", resp.StatusCode) + reporter.ReportErrorWithSentryOptions(statusErr, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": url, + }, + Level: sentry.LevelError, + }) + return nil, statusErr } data, err := io.ReadAll(resp.Body) if err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": url, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to read remote config: %v", err) } var servers []models.ObaServer if err := json.Unmarshal(data, &servers); err != nil { + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "config_url": url, + }, + Level: sentry.LevelError, + }) return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) } From 123890a42643dd2a9449cb02d1a73afc8c1e6d4c Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Sun, 15 Jun 2025 02:55:38 +0300 Subject: [PATCH 3/8] test(main): integrate reporter with main_test.go --- cmd/watchdog/main_test.go | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/cmd/watchdog/main_test.go b/cmd/watchdog/main_test.go index b7119b4..7277d76 100644 --- a/cmd/watchdog/main_test.go +++ b/cmd/watchdog/main_test.go @@ -15,9 +15,11 @@ import ( "time" "watchdog.onebusaway.org/internal/models" + "watchdog.onebusaway.org/internal/report" ) func TestLoadConfigFromFile(t *testing.T) { + reporter := report.NewReporter("test", "development") t.Run("ValidConfig", func(t *testing.T) { content := `[{ "name": "Test Server", "id": 1, @@ -40,7 +42,7 @@ func TestLoadConfigFromFile(t *testing.T) { } tmpFile.Close() - servers, err := loadConfigFromFile(tmpFile.Name()) + servers, err := loadConfigFromFile(tmpFile.Name() , reporter) if err != nil { t.Fatalf("loadConfigFromFile failed: %v", err) } @@ -79,14 +81,14 @@ func TestLoadConfigFromFile(t *testing.T) { } tmpFile.Close() - _, err = loadConfigFromFile(tmpFile.Name()) + _, err = loadConfigFromFile(tmpFile.Name() , reporter) if err == nil { t.Errorf("Expected error with invalid JSON, got none") } }) t.Run("NonExistentFile", func(t *testing.T) { - _, err := loadConfigFromFile("non-existent-file.json") + _, err := loadConfigFromFile("non-existent-file.json" , reporter) if err == nil { t.Errorf("Expected error for non-existent file, got none") } @@ -94,6 +96,7 @@ func TestLoadConfigFromFile(t *testing.T) { } func TestLoadConfigFromURL(t *testing.T) { + reporter := report.NewReporter("test", "development") t.Run("ValidResponse", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -110,7 +113,7 @@ func TestLoadConfigFromURL(t *testing.T) { })) defer ts.Close() - servers, err := loadConfigFromURL(ts.URL, "user", "pass") + servers, err := loadConfigFromURL(ts.URL, "user", "pass" , reporter) if err != nil { t.Fatalf("loadConfigFromURL failed: %v", err) } @@ -140,7 +143,7 @@ func TestLoadConfigFromURL(t *testing.T) { })) defer ts.Close() - _, err := loadConfigFromURL(ts.URL, "", "") + _, err := loadConfigFromURL(ts.URL, "", "" , reporter) if err == nil { t.Errorf("Expected error with 500 response, got none") } @@ -153,13 +156,13 @@ func TestLoadConfigFromURL(t *testing.T) { })) defer ts.Close() - _, err := loadConfigFromURL(ts.URL, "", "") + _, err := loadConfigFromURL(ts.URL, "", "" , reporter) if err == nil { t.Errorf("Expected error for invalid JSON response, got none") } }) t.Run("InvalidURL", func(t *testing.T) { - _, err := loadConfigFromURL("://invalid-url", "", "") + _, err := loadConfigFromURL("://invalid-url", "", "" , reporter) if err == nil || !strings.Contains(err.Error(), "failed to create request") { t.Errorf("Expected request creation error, got: %v", err) } @@ -295,12 +298,13 @@ func TestUpdateConfig(t *testing.T) { func TestCreateCacheDirectory(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - + reporter := report.NewReporter("test", "development") + t.Run("Creates new directory", func(t *testing.T) { baseTempDir := t.TempDir() tempDir := filepath.Join(baseTempDir, "test-cache") - err := createCacheDirectory(tempDir, logger) + err := createCacheDirectory(tempDir, logger , reporter) if err != nil { t.Fatalf("Failed to create cache directory: %v", err) } @@ -322,7 +326,7 @@ func TestCreateCacheDirectory(t *testing.T) { t.Fatalf("Failed to create test directory: %v", err) } - err := createCacheDirectory(tempDir, logger) + err := createCacheDirectory(tempDir, logger , reporter) if err != nil { t.Errorf("Failed on existing directory: %v", err) } @@ -338,7 +342,7 @@ func TestCreateCacheDirectory(t *testing.T) { file.Close() } - err := createCacheDirectory(filePath, logger) + err := createCacheDirectory(filePath, logger , reporter) if err == nil { t.Error("Expected error when path is a file, but got nil") } @@ -351,7 +355,8 @@ func TestRefreshConfig(t *testing.T) { app := newTestApplication(t) testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) - + reporter := report.NewReporter("test", "development") + var serverHitCount int mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serverHitCount++ @@ -378,7 +383,7 @@ func TestRefreshConfig(t *testing.T) { originalConfig := make([]models.ObaServer, len(app.config.Servers)) copy(originalConfig, app.config.Servers) - go refreshConfig(mockServer.URL, "testuser", "testpass", app, testLogger, 100*time.Millisecond) + go refreshConfig(mockServer.URL, "testuser", "testpass", app, testLogger, 100*time.Millisecond , reporter) time.Sleep(200 * time.Millisecond) @@ -414,19 +419,21 @@ func TestDownloadGTFSBundles(t *testing.T) { tempDir := t.TempDir() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + reporter := report.NewReporter("test", "development") - downloadGTFSBundles(servers, tempDir, logger) + downloadGTFSBundles(servers, tempDir, logger , reporter) } func TestRefreshGTFSBundles(t *testing.T) { var logBuffer bytes.Buffer logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug})) - + reporter := report.NewReporter("test", "development") + servers := []models.ObaServer{{ID: 1, Name: "Test Server", GtfsUrl: "http://example.com/gtfs.zip"}} cacheDir := t.TempDir() - go refreshGTFSBundles(servers, cacheDir, logger, 10*time.Millisecond) + go refreshGTFSBundles(servers, cacheDir, logger, 10*time.Millisecond , reporter) time.Sleep(15*time.Millisecond) From 60e0e2b917d922ddda999a8b0f68a89f5739cdf1 Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Sun, 15 Jun 2025 02:57:06 +0300 Subject: [PATCH 4/8] feat(metrics): integrate Sentry error reporting into collectMetricsForServer --- cmd/watchdog/metrics.go | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cmd/watchdog/metrics.go b/cmd/watchdog/metrics.go index fe0b1e1..25d260e 100644 --- a/cmd/watchdog/metrics.go +++ b/cmd/watchdog/metrics.go @@ -1,10 +1,13 @@ package main import ( + "fmt" "time" + "github.com/getsentry/sentry-go" "watchdog.onebusaway.org/internal/metrics" "watchdog.onebusaway.org/internal/models" + "watchdog.onebusaway.org/internal/report" "watchdog.onebusaway.org/internal/utils" ) @@ -33,29 +36,73 @@ func (app *application) collectMetricsForServer(server models.ObaServer) { cachePath, err := utils.GetLastCachedFile("cache", server.ID) if err != nil { app.logger.Error("Failed to get last cached file", "error", err) + app.reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + "server_name": server.Name, + }, + Level: sentry.LevelError, + }) return } _, _, err = metrics.CheckBundleExpiration(cachePath, app.logger, time.Now(), server) if err != nil { app.logger.Error("Failed to check GTFS bundle expiration", "error", err) + app.reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + "server_name": server.Name, + }, + ExtraContext: map[string]interface{}{ + "cache_file": cachePath, + }, + Level: sentry.LevelError, + }) } err = metrics.CheckAgenciesWithCoverageMatch(cachePath, app.logger, server) if err != nil { app.logger.Error("Failed to check agencies with coverage match metric", "error", err) + app.reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + "server_name": server.Name, + }, + ExtraContext: map[string]interface{}{ + "cache_file": cachePath, + }, + Level: sentry.LevelError, + }) } err = metrics.CheckVehicleCountMatch(server) if err != nil { app.logger.Error("Failed to check vehicle count match metric", "error", err) + app.reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + "server_name": server.Name, + }, + Level: sentry.LevelError, + }) } err = metrics.FetchObaAPIMetrics(server.AgencyID, server.ObaBaseURL, server.ObaApiKey, nil) if err != nil { app.logger.Error("Failed to fetch OBA API metrics", "error", err) + app.reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Tags: map[string]string{ + "server_id": fmt.Sprintf("%d", server.ID), + "server_name": server.Name, + }, + ExtraContext: map[string]interface{}{ + "oba_base_url": server.ObaBaseURL, + }, + Level: sentry.LevelError, + }) } } From 4c61601c84cf98ee57c6497517087e2dbefe175d Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Mon, 16 Jun 2025 01:06:14 +0300 Subject: [PATCH 5/8] style: fix indentation, remove stray and extra spaces (ran go fmt) --- cmd/watchdog/main.go | 120 ++++++++-------- cmd/watchdog/main_test.go | 268 +++++++++++++++++------------------ cmd/watchdog/metrics_test.go | 10 +- internal/report/reporter.go | 5 +- internal/report/sentry.go | 1 - 5 files changed, 195 insertions(+), 209 deletions(-) diff --git a/cmd/watchdog/main.go b/cmd/watchdog/main.go index fe31601..8593b06 100644 --- a/cmd/watchdog/main.go +++ b/cmd/watchdog/main.go @@ -16,9 +16,9 @@ import ( "github.com/getsentry/sentry-go" "watchdog.onebusaway.org/internal/models" + "watchdog.onebusaway.org/internal/report" "watchdog.onebusaway.org/internal/server" "watchdog.onebusaway.org/internal/utils" - "watchdog.onebusaway.org/internal/report" ) // Declare a string containing the application version number. Later in the book we'll @@ -31,10 +31,10 @@ const version = "1.0.0" // logger, but it will grow to include a lot more as our build progresses. type application struct { - config server.Config - logger *slog.Logger + config server.Config + logger *slog.Logger reporter *report.Reporter - mu sync.RWMutex + mu sync.RWMutex } func main() { @@ -54,9 +54,9 @@ func main() { configAuthPass := os.Getenv("CONFIG_AUTH_PASS") var err error - - if err = validateConfigFlags(configFile, configURL); err != nil{ - fmt.Println("Error:",err) + + if err = validateConfigFlags(configFile, configURL); err != nil { + fmt.Println("Error:", err) flag.Usage() os.Exit(1) } @@ -68,12 +68,11 @@ func main() { reporter.ConfigureScope() var servers []models.ObaServer - if *configFile != "" { - servers, err = loadConfigFromFile(*configFile , reporter) + servers, err = loadConfigFromFile(*configFile, reporter) } else if *configURL != "" { - servers, err = loadConfigFromURL(*configURL, configAuthUser, configAuthPass , reporter) + servers, err = loadConfigFromURL(*configURL, configAuthUser, configAuthPass, reporter) } else { fmt.Println("Error: No configuration provided. Use --config-file or --config-url.") flag.Usage() @@ -94,31 +93,29 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - - cacheDir := "cache" - if err = createCacheDirectory(cacheDir, logger , reporter); err != nil { + if err = createCacheDirectory(cacheDir, logger, reporter); err != nil { logger.Error("Failed to create cache directory", "error", err) os.Exit(1) } // Download GTFS bundles for all servers on startup - downloadGTFSBundles(servers, cacheDir, logger , reporter) + downloadGTFSBundles(servers, cacheDir, logger, reporter) app := &application{ - config: cfg, - logger: logger, + config: cfg, + logger: logger, reporter: reporter, } app.startMetricsCollection() // Cron job to download GTFS bundles for all servers every 24 hours - go refreshGTFSBundles(servers, cacheDir, logger , 24 * time.Hour , reporter) + go refreshGTFSBundles(servers, cacheDir, logger, 24*time.Hour, reporter) // If a remote URL is specified, refresh the configuration every minute if *configURL != "" { - go refreshConfig(*configURL, configAuthUser, configAuthPass, app, logger, time.Minute , reporter) + go refreshConfig(*configURL, configAuthUser, configAuthPass, app, logger, time.Minute, reporter) } srv := &http.Server{ @@ -139,49 +136,48 @@ func main() { } // validateConfigFlags checks that only one of --config-file, --config-url, or an additional argument is provided. -func validateConfigFlags(configFile, configURL *string) error{ +func validateConfigFlags(configFile, configURL *string) error { if (*configFile != "" && *configURL != "") || (*configFile != "" && len(flag.Args()) > 0) || (*configURL != "" && len(flag.Args()) > 0) { return fmt.Errorf("only one of --config-file or --config-url can be specified") } return nil } - // createCacheDirectory ensures the cache directory exists, creating it if necessary. -func createCacheDirectory(cacheDir string , logger *slog.Logger , reporter *report.Reporter) error{ - stat, err := os.Stat(cacheDir); +func createCacheDirectory(cacheDir string, logger *slog.Logger, reporter *report.Reporter) error { + stat, err := os.Stat(cacheDir) if err != nil { - if os.IsNotExist(err){ + if os.IsNotExist(err) { if err := os.MkdirAll(cacheDir, os.ModePerm); err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Level: sentry.LevelError, - ExtraContext: map[string]interface{}{ - "cache_dir": cacheDir, - }, - }) + Level: sentry.LevelError, + ExtraContext: map[string]interface{}{ + "cache_dir": cacheDir, + }, + }) return err } return nil } return err - + } if !stat.IsDir() { err := fmt.Errorf("%s is not a directory", cacheDir) - reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Level: sentry.LevelError, - ExtraContext: map[string]interface{}{ - "cache_dir": cacheDir, - }, - }) - return err + reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ + Level: sentry.LevelError, + ExtraContext: map[string]interface{}{ + "cache_dir": cacheDir, + }, + }) + return err } return nil } // downloadGTFSBundles downloads GTFS bundles for each server and caches them locally. -func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger , reporter *report.Reporter) { +func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger, reporter *report.Reporter) { for _, server := range servers { hash := sha1.Sum([]byte(server.GtfsUrl)) hashStr := hex.EncodeToString(hash[:]) @@ -190,13 +186,13 @@ func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *sl _, err := utils.DownloadGTFSBundle(server.GtfsUrl, cacheDir, server.ID, hashStr) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ + Tags: map[string]string{ "server_id": fmt.Sprintf("%d", server.ID), - }, - ExtraContext: map[string]interface{}{ + }, + ExtraContext: map[string]interface{}{ "gtfs_url": server.GtfsUrl, - }, - Level: sentry.LevelError, + }, + Level: sentry.LevelError, }) logger.Error("Failed to download GTFS bundle", "server_id", server.ID, "error", err) } else { @@ -206,22 +202,22 @@ func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *sl } // refreshGTFSBundles periodically downloads GTFS bundles at the specified interval. -func refreshGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger , interval time.Duration , reporter *report.Reporter) { +func refreshGTFSBundles(servers []models.ObaServer, cacheDir string, logger *slog.Logger, interval time.Duration, reporter *report.Reporter) { for { time.Sleep(interval) - downloadGTFSBundles(servers, cacheDir, logger , reporter) + downloadGTFSBundles(servers, cacheDir, logger, reporter) } } // refreshConfig periodically fetches remote config and updates the application servers. -func refreshConfig(configURL, configAuthUser, configAuthPass string, app *application, logger *slog.Logger , interval time.Duration , reporter *report.Reporter) { +func refreshConfig(configURL, configAuthUser, configAuthPass string, app *application, logger *slog.Logger, interval time.Duration, reporter *report.Reporter) { for { time.Sleep(interval) - newServers, err := loadConfigFromURL(configURL, configAuthUser, configAuthPass , reporter) + newServers, err := loadConfigFromURL(configURL, configAuthUser, configAuthPass, reporter) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ Tags: map[string]string{ - "config_url": configURL, + "config_url": configURL, }, Level: sentry.LevelError, }) @@ -241,15 +237,14 @@ func (app *application) updateConfig(newServers []models.ObaServer) { app.config.Servers = newServers } - -func loadConfigFromFile(filePath string , reporter *report.Reporter) ([]models.ObaServer, error) { +func loadConfigFromFile(filePath string, reporter *report.Reporter) ([]models.ObaServer, error) { data, err := os.ReadFile(filePath) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "file_path": filePath, - }, - Level: sentry.LevelError, + Tags: map[string]string{ + "file_path": filePath, + }, + Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to read config file: %v", err) } @@ -257,10 +252,10 @@ func loadConfigFromFile(filePath string , reporter *report.Reporter) ([]models.O var servers []models.ObaServer if err := json.Unmarshal(data, &servers); err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "file_path": filePath, - }, - Level: sentry.LevelError, + Tags: map[string]string{ + "file_path": filePath, + }, + Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) } @@ -268,13 +263,13 @@ func loadConfigFromFile(filePath string , reporter *report.Reporter) ([]models.O return servers, nil } -func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporter) ([]models.ObaServer, error) { +func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter) ([]models.ObaServer, error) { client := &http.Client{} req, err := http.NewRequest("GET", url, nil) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ Tags: map[string]string{ - "config_url": url, + "config_url": url, }, Level: sentry.LevelError, }) @@ -289,7 +284,7 @@ func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporte if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ Tags: map[string]string{ - "config_url": url, + "config_url": url, }, Level: sentry.LevelError, }) @@ -312,7 +307,7 @@ func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporte if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ Tags: map[string]string{ - "config_url": url, + "config_url": url, }, Level: sentry.LevelError, }) @@ -323,7 +318,7 @@ func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporte if err := json.Unmarshal(data, &servers); err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ Tags: map[string]string{ - "config_url": url, + "config_url": url, }, Level: sentry.LevelError, }) @@ -332,4 +327,3 @@ func loadConfigFromURL(url, authUser, authPass string , reporter *report.Reporte return servers, nil } - diff --git a/cmd/watchdog/main_test.go b/cmd/watchdog/main_test.go index 7277d76..78d55bd 100644 --- a/cmd/watchdog/main_test.go +++ b/cmd/watchdog/main_test.go @@ -42,7 +42,7 @@ func TestLoadConfigFromFile(t *testing.T) { } tmpFile.Close() - servers, err := loadConfigFromFile(tmpFile.Name() , reporter) + servers, err := loadConfigFromFile(tmpFile.Name(), reporter) if err != nil { t.Fatalf("loadConfigFromFile failed: %v", err) } @@ -81,14 +81,14 @@ func TestLoadConfigFromFile(t *testing.T) { } tmpFile.Close() - _, err = loadConfigFromFile(tmpFile.Name() , reporter) + _, err = loadConfigFromFile(tmpFile.Name(), reporter) if err == nil { t.Errorf("Expected error with invalid JSON, got none") } }) t.Run("NonExistentFile", func(t *testing.T) { - _, err := loadConfigFromFile("non-existent-file.json" , reporter) + _, err := loadConfigFromFile("non-existent-file.json", reporter) if err == nil { t.Errorf("Expected error for non-existent file, got none") } @@ -113,7 +113,7 @@ func TestLoadConfigFromURL(t *testing.T) { })) defer ts.Close() - servers, err := loadConfigFromURL(ts.URL, "user", "pass" , reporter) + servers, err := loadConfigFromURL(ts.URL, "user", "pass", reporter) if err != nil { t.Fatalf("loadConfigFromURL failed: %v", err) } @@ -143,7 +143,7 @@ func TestLoadConfigFromURL(t *testing.T) { })) defer ts.Close() - _, err := loadConfigFromURL(ts.URL, "", "" , reporter) + _, err := loadConfigFromURL(ts.URL, "", "", reporter) if err == nil { t.Errorf("Expected error with 500 response, got none") } @@ -155,14 +155,14 @@ func TestLoadConfigFromURL(t *testing.T) { w.Write([]byte(`{ this is not valid JSON }`)) })) defer ts.Close() - - _, err := loadConfigFromURL(ts.URL, "", "" , reporter) + + _, err := loadConfigFromURL(ts.URL, "", "", reporter) if err == nil { t.Errorf("Expected error for invalid JSON response, got none") } }) t.Run("InvalidURL", func(t *testing.T) { - _, err := loadConfigFromURL("://invalid-url", "", "" , reporter) + _, err := loadConfigFromURL("://invalid-url", "", "", reporter) if err == nil || !strings.Contains(err.Error(), "failed to create request") { t.Errorf("Expected request creation error, got: %v", err) } @@ -216,81 +216,78 @@ func parseFlags() (string, string, error) { return *configFile, *configURL, nil } - - - func TestValidateConfigFlags(t *testing.T) { tests := []struct { - name string - configFile string - configURL string - extraArgs []string - expectError bool + name string + configFile string + configURL string + extraArgs []string + expectError bool }{ - {"No config", "", "", nil, false}, - {"Valid local config", "config.json", "", nil, false}, - {"Valid remote config", "", "http://example.com/config.json", nil, false}, - {"Both config file and URL", "config.json", "http://example.com/config.json", nil, true}, - {"Config file with extra args", "config.json", "", []string{"extraArg"}, true}, - {"Config URL with extra args", "", "http://example.com/config.json", []string{"extraArg"}, true}, + {"No config", "", "", nil, false}, + {"Valid local config", "config.json", "", nil, false}, + {"Valid remote config", "", "http://example.com/config.json", nil, false}, + {"Both config file and URL", "config.json", "http://example.com/config.json", nil, true}, + {"Config file with extra args", "config.json", "", []string{"extraArg"}, true}, + {"Config URL with extra args", "", "http://example.com/config.json", []string{"extraArg"}, true}, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - flag.CommandLine = flag.NewFlagSet(tt.name, flag.ContinueOnError) - var output bytes.Buffer - flag.CommandLine.SetOutput(&output) - - configFile := flag.String("config-file", "", "Path to config file") - configURL := flag.String("config-url", "", "URL to config") - - args := []string{"cmd"} - if tt.configFile != "" { - args = append(args, "--config-file="+tt.configFile) - } - if tt.configURL != "" { - args = append(args, "--config-url="+tt.configURL) - } - args = append(args, tt.extraArgs...) - - os.Args = args - flag.CommandLine.Parse(args[1:]) - - err := validateConfigFlags(configFile, configURL) - - if (err != nil) != tt.expectError { - t.Errorf("Expected error: %v, got: %v", tt.expectError, err) - } - - if err != nil && !strings.Contains(err.Error(), "only one of --config-file or --config-url") { - t.Errorf("Unexpected error message: %v", err) - } - }) + t.Run(tt.name, func(t *testing.T) { + flag.CommandLine = flag.NewFlagSet(tt.name, flag.ContinueOnError) + var output bytes.Buffer + flag.CommandLine.SetOutput(&output) + + configFile := flag.String("config-file", "", "Path to config file") + configURL := flag.String("config-url", "", "URL to config") + + args := []string{"cmd"} + if tt.configFile != "" { + args = append(args, "--config-file="+tt.configFile) + } + if tt.configURL != "" { + args = append(args, "--config-url="+tt.configURL) + } + args = append(args, tt.extraArgs...) + + os.Args = args + flag.CommandLine.Parse(args[1:]) + + err := validateConfigFlags(configFile, configURL) + + if (err != nil) != tt.expectError { + t.Errorf("Expected error: %v, got: %v", tt.expectError, err) + } + + if err != nil && !strings.Contains(err.Error(), "only one of --config-file or --config-url") { + t.Errorf("Unexpected error message: %v", err) + } + }) } } func TestUpdateConfig(t *testing.T) { app := &application{} - + initialServers := []models.ObaServer{ {ID: 1, Name: "Server 1"}, } - + newServers := []models.ObaServer{ {ID: 1, Name: "Server 1 Updated"}, {ID: 2, Name: "Server 2"}, } - + app.updateConfig(initialServers) if len(app.config.Servers) != 1 { t.Errorf("Expected 1 server, got %d", len(app.config.Servers)) } - + app.updateConfig(newServers) if len(app.config.Servers) != 2 { t.Errorf("Expected 2 servers, got %d", len(app.config.Servers)) } - + if app.config.Servers[0].Name != "Server 1 Updated" { t.Errorf("Expected server name to be updated to 'Server 1 Updated', got %s", app.config.Servers[0].Name) } @@ -301,74 +298,73 @@ func TestCreateCacheDirectory(t *testing.T) { reporter := report.NewReporter("test", "development") t.Run("Creates new directory", func(t *testing.T) { - baseTempDir := t.TempDir() - tempDir := filepath.Join(baseTempDir, "test-cache") - - err := createCacheDirectory(tempDir, logger , reporter) - if err != nil { - t.Fatalf("Failed to create cache directory: %v", err) - } - - stat, err := os.Stat(tempDir) - if err != nil { - t.Fatalf("Failed to stat directory: %v", err) - } - if !stat.IsDir() { - t.Error("Cache directory was created but is not a directory") - } + baseTempDir := t.TempDir() + tempDir := filepath.Join(baseTempDir, "test-cache") + + err := createCacheDirectory(tempDir, logger, reporter) + if err != nil { + t.Fatalf("Failed to create cache directory: %v", err) + } + + stat, err := os.Stat(tempDir) + if err != nil { + t.Fatalf("Failed to stat directory: %v", err) + } + if !stat.IsDir() { + t.Error("Cache directory was created but is not a directory") + } }) - + t.Run("Handles existing directory", func(t *testing.T) { - baseTempDir := t.TempDir() - tempDir := filepath.Join(baseTempDir, "test-cache") - - if err := os.MkdirAll(tempDir, os.ModePerm); err != nil { - t.Fatalf("Failed to create test directory: %v", err) - } - - err := createCacheDirectory(tempDir, logger , reporter) - if err != nil { - t.Errorf("Failed on existing directory: %v", err) - } + baseTempDir := t.TempDir() + tempDir := filepath.Join(baseTempDir, "test-cache") + + if err := os.MkdirAll(tempDir, os.ModePerm); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + err := createCacheDirectory(tempDir, logger, reporter) + if err != nil { + t.Errorf("Failed on existing directory: %v", err) + } }) - + t.Run("Fails: if path is a file", func(t *testing.T) { - baseTempDir := t.TempDir() - filePath := filepath.Join(baseTempDir, "test-file") - - if file, err := os.Create(filePath); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } else { - file.Close() - } - - err := createCacheDirectory(filePath, logger , reporter) - if err == nil { - t.Error("Expected error when path is a file, but got nil") - } + baseTempDir := t.TempDir() + filePath := filepath.Join(baseTempDir, "test-file") + + if file, err := os.Create(filePath); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } else { + file.Close() + } + + err := createCacheDirectory(filePath, logger, reporter) + if err == nil { + t.Error("Expected error when path is a file, but got nil") + } }) - } func TestRefreshConfig(t *testing.T) { app := newTestApplication(t) - + testLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) reporter := report.NewReporter("test", "development") var serverHitCount int mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - serverHitCount++ - - user, pass, hasAuth := r.BasicAuth() - if hasAuth && (user != "testuser" || pass != "testpass") { - w.WriteHeader(http.StatusUnauthorized) - return - } - - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, `[ + serverHitCount++ + + user, pass, hasAuth := r.BasicAuth() + if hasAuth && (user != "testuser" || pass != "testpass") { + w.WriteHeader(http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `[ { "id": 999, "name": "Refreshed Test Server", @@ -379,36 +375,36 @@ func TestRefreshConfig(t *testing.T) { ]`) })) defer mockServer.Close() - + originalConfig := make([]models.ObaServer, len(app.config.Servers)) copy(originalConfig, app.config.Servers) - - go refreshConfig(mockServer.URL, "testuser", "testpass", app, testLogger, 100*time.Millisecond , reporter) - + + go refreshConfig(mockServer.URL, "testuser", "testpass", app, testLogger, 100*time.Millisecond, reporter) + time.Sleep(200 * time.Millisecond) - + if serverHitCount == 0 { - t.Fatal("Mock server was never called") + t.Fatal("Mock server was never called") } - + app.mu.RLock() updatedServers := app.config.Servers app.mu.RUnlock() - + if len(updatedServers) == 0 { - t.Fatal("No servers found in updated configuration") + t.Fatal("No servers found in updated configuration") } - + var found bool for _, s := range updatedServers { - if s.ID == 999 && s.Name == "Refreshed Test Server" { - found = true - break - } + if s.ID == 999 && s.Name == "Refreshed Test Server" { + found = true + break + } } - + if !found { - t.Errorf("Config not updated with refreshed server data. Original: %+v, Updated: %+v", originalConfig, updatedServers) + t.Errorf("Config not updated with refreshed server data. Original: %+v, Updated: %+v", originalConfig, updatedServers) } } @@ -416,13 +412,13 @@ func TestDownloadGTFSBundles(t *testing.T) { servers := []models.ObaServer{ {ID: 1, GtfsUrl: "https://example.com/gtfs.zip"}, } - + tempDir := t.TempDir() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) reporter := report.NewReporter("test", "development") - - downloadGTFSBundles(servers, tempDir, logger , reporter) - + + downloadGTFSBundles(servers, tempDir, logger, reporter) + } func TestRefreshGTFSBundles(t *testing.T) { @@ -432,10 +428,10 @@ func TestRefreshGTFSBundles(t *testing.T) { servers := []models.ObaServer{{ID: 1, Name: "Test Server", GtfsUrl: "http://example.com/gtfs.zip"}} cacheDir := t.TempDir() - - go refreshGTFSBundles(servers, cacheDir, logger, 10*time.Millisecond , reporter) - - time.Sleep(15*time.Millisecond) - + + go refreshGTFSBundles(servers, cacheDir, logger, 10*time.Millisecond, reporter) + + time.Sleep(15 * time.Millisecond) + t.Log("refreshGTFSBundles executed without crashing") -} \ No newline at end of file +} diff --git a/cmd/watchdog/metrics_test.go b/cmd/watchdog/metrics_test.go index d1e5355..0a1ecb9 100644 --- a/cmd/watchdog/metrics_test.go +++ b/cmd/watchdog/metrics_test.go @@ -43,12 +43,12 @@ func TestMetricsEndpoint(t *testing.T) { func TestCollectMetricsForServer(t *testing.T) { app := newTestApplication(t) - + prometheus.DefaultRegisterer = prometheus.NewRegistry() - + testServer := app.config.Servers[0] - + app.collectMetricsForServer(testServer) - + getMetricsForTesting(t, metrics.ObaApiStatus) -} \ No newline at end of file +} diff --git a/internal/report/reporter.go b/internal/report/reporter.go index 92aefa7..9bbdb6e 100644 --- a/internal/report/reporter.go +++ b/internal/report/reporter.go @@ -7,7 +7,6 @@ import ( "github.com/getsentry/sentry-go" ) - // Reporter provides methods to configure Sentry and report errors // with environment-specific metadata (like env, version, arch, etc.). type Reporter struct { @@ -29,7 +28,7 @@ func (r *Reporter) ConfigureScope() { scope.SetTag("env", r.Env) scope.SetTag("app_version", r.Version) scope.SetTag("go_version", runtime.Version()) - scope.SetTag("goarch", runtime.GOARCH) + scope.SetTag("goarch", runtime.GOARCH) scope.SetContext("host_info", map[string]interface{}{ "hostname": r.getHostname(), }) @@ -46,7 +45,6 @@ func (r *Reporter) getHostname() string { return hostname } - // ReportError reports the error to Sentry with the given severity level // If no level is provided, it defaults to sentry.LevelError. func (r *Reporter) ReportError(err error, levels ...sentry.Level) { @@ -65,7 +63,6 @@ func (r *Reporter) ReportError(err error, levels ...sentry.Level) { }) } - // SentryReportOptions provides optional data for reporting. type SentryReportOptions struct { ExtraContext map[string]interface{} diff --git a/internal/report/sentry.go b/internal/report/sentry.go index 8f348bc..317ff36 100644 --- a/internal/report/sentry.go +++ b/internal/report/sentry.go @@ -20,7 +20,6 @@ func SetupSentry() { sentry.CaptureMessage("Watchdog started") } - func FlushSentry() { sentry.Flush(2 * time.Second) } From 834bb2fb744e215a5111d0d6ebb07a4965fd4fd8 Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Mon, 16 Jun 2025 02:07:25 +0300 Subject: [PATCH 6/8] feat(utils): add MakeMap helper for map[string]string creation --- internal/utils/helpers.go | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 internal/utils/helpers.go diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go new file mode 100644 index 0000000..5d6fc72 --- /dev/null +++ b/internal/utils/helpers.go @@ -0,0 +1,5 @@ +package utils + +func MakeMap(key, value string) map[string]string { + return map[string]string{key: value} +} From ee84715f8e2f4fd84b22cf224e67bf8f570646ca Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Mon, 16 Jun 2025 02:11:02 +0300 Subject: [PATCH 7/8] refactor(main): use MakeMap util for map creation --- cmd/watchdog/main.go | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/cmd/watchdog/main.go b/cmd/watchdog/main.go index 8593b06..9fc9b65 100644 --- a/cmd/watchdog/main.go +++ b/cmd/watchdog/main.go @@ -186,9 +186,7 @@ func downloadGTFSBundles(servers []models.ObaServer, cacheDir string, logger *sl _, err := utils.DownloadGTFSBundle(server.GtfsUrl, cacheDir, server.ID, hashStr) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "server_id": fmt.Sprintf("%d", server.ID), - }, + Tags: utils.MakeMap("server_id", fmt.Sprintf("%d", server.ID)), ExtraContext: map[string]interface{}{ "gtfs_url": server.GtfsUrl, }, @@ -216,9 +214,7 @@ func refreshConfig(configURL, configAuthUser, configAuthPass string, app *applic newServers, err := loadConfigFromURL(configURL, configAuthUser, configAuthPass, reporter) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": configURL, - }, + Tags: utils.MakeMap("config_url", configURL), Level: sentry.LevelError, }) logger.Error("Failed to refresh remote config", "error", err) @@ -241,9 +237,7 @@ func loadConfigFromFile(filePath string, reporter *report.Reporter) ([]models.Ob data, err := os.ReadFile(filePath) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "file_path": filePath, - }, + Tags: utils.MakeMap("file_path", filePath), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to read config file: %v", err) @@ -252,9 +246,7 @@ func loadConfigFromFile(filePath string, reporter *report.Reporter) ([]models.Ob var servers []models.ObaServer if err := json.Unmarshal(data, &servers); err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "file_path": filePath, - }, + Tags: utils.MakeMap("file_path", filePath), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) @@ -268,9 +260,7 @@ func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter req, err := http.NewRequest("GET", url, nil) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": url, - }, + Tags: utils.MakeMap("config_url", url), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to create request: %v", err) @@ -283,9 +273,7 @@ func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter resp, err := client.Do(req) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": url, - }, + Tags: utils.MakeMap("config_url", url), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to fetch remote config: %v", err) @@ -295,9 +283,7 @@ func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter if resp.StatusCode != http.StatusOK { statusErr := fmt.Errorf("remote config returned status: %d", resp.StatusCode) reporter.ReportErrorWithSentryOptions(statusErr, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": url, - }, + Tags: utils.MakeMap("config_url", url), Level: sentry.LevelError, }) return nil, statusErr @@ -306,9 +292,7 @@ func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter data, err := io.ReadAll(resp.Body) if err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": url, - }, + Tags: utils.MakeMap("config_url", url), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to read remote config: %v", err) @@ -317,9 +301,7 @@ func loadConfigFromURL(url, authUser, authPass string, reporter *report.Reporter var servers []models.ObaServer if err := json.Unmarshal(data, &servers); err != nil { reporter.ReportErrorWithSentryOptions(err, report.SentryReportOptions{ - Tags: map[string]string{ - "config_url": url, - }, + Tags: utils.MakeMap("config_url", url), Level: sentry.LevelError, }) return nil, fmt.Errorf("failed to unmarshal JSON: %v", err) From a1bf3ebcae69235e4c8a69ea7ad794c9c17a055d Mon Sep 17 00:00:00 2001 From: Abo-Omar-74 Date: Mon, 16 Jun 2025 04:46:20 +0300 Subject: [PATCH 8/8] docs(utils): add documentation for MakeMap helper function --- internal/utils/helpers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go index 5d6fc72..1cf79f9 100644 --- a/internal/utils/helpers.go +++ b/internal/utils/helpers.go @@ -1,5 +1,6 @@ package utils +// MakeMap creates and returns a map[string]string containing a single key-value pair. func MakeMap(key, value string) map[string]string { return map[string]string{key: value} }