this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement environment modes (dev/prod)

Introduces a new `Mode` configuration to tailor application behavior for
development versus production environments.

Key changes:
- **Configuration**: Added `TUMBLE_MODE` (default: `production`).
- **Logging**:
- *Dev*: Human-readable text, DEBUG level, full SQL query logging.
- *Prod*: Structured JSON, INFO level, error-only SQL logging.
- **Templates**: Added hot-reloading for development. Production continues to
use embedded assets for performance.
- **Error Handling**: Development mode now exposes detailed error messages and
stack traces (context depending), while production returns generic 500
errors.
- **Documentation**: Updated README with environment variable details.

+125 -37
+12
README.md
··· 107 107 - `TUMBLE_PORT=9090` 108 108 - `TUMBLE_DRIVER=mysql` 109 109 - `TUMBLE_DATABASE=production_db` 110 + - `TUMBLE_MODE=development` (Options: `development`, `production`. Default: `production`) 110 111 - `TUMBLE_LOGGING_LEVEL=debug` 112 + 113 + ### Environment Modes (`TUMBLE_MODE`) 114 + 115 + - **development**: 116 + - **Logging**: Text format, Debug level, Full SQL query logging. 117 + - **Templates**: Hot-reloading from disk (edit HTML files to see changes immediately). 118 + - **Errors**: Displays detailed error messages in the browser. 119 + - **production** (default): 120 + - **Logging**: JSON format, Info level, Error-only SQL logging. 121 + - **Templates**: Cached in memory for performance. 122 + - **Errors**: Displays generic "Internal Server Error" message to users. 111 123 112 124 ### 2. Initialize Database 113 125
+28 -14
cmd/tumble/main.go
··· 83 83 } 84 84 85 85 var level slog.Level 86 - switch cfg.Logging.Level { 87 - case "debug", "verbose": 88 - level = slog.LevelDebug 89 - case "warn": 90 - level = slog.LevelWarn 91 - case "error": 92 - level = slog.LevelError 93 - default: 94 - level = slog.LevelInfo 86 + // Default level based on Mode if not explicitly set 87 + if cfg.Logging.Level == "" { 88 + if cfg.Mode == "development" { 89 + level = slog.LevelDebug 90 + } else { 91 + level = slog.LevelInfo 92 + } 93 + } else { 94 + switch cfg.Logging.Level { 95 + case "debug", "verbose": 96 + level = slog.LevelDebug 97 + case "warn": 98 + level = slog.LevelWarn 99 + case "error": 100 + level = slog.LevelError 101 + default: 102 + level = slog.LevelInfo 103 + } 95 104 } 96 105 97 - logger := slog.New(slog.NewTextHandler(output, &slog.HandlerOptions{ 98 - Level: level, 99 - })) 106 + var logHandler slog.Handler 107 + if cfg.Mode == "production" { 108 + logHandler = slog.NewJSONHandler(output, &slog.HandlerOptions{Level: level}) 109 + } else { 110 + logHandler = slog.NewTextHandler(output, &slog.HandlerOptions{Level: level}) 111 + } 112 + 113 + logger := slog.New(logHandler) 100 114 slog.SetDefault(logger) 101 115 102 116 // Init DB 103 - store, err := data.NewStore(cfg.Driver, cfg.DSN()) 117 + store, err := data.NewStore(cfg) 104 118 if err != nil { 105 119 slog.Error("Fatal: Could not connect to DB", "error", err) 106 120 os.Exit(1) ··· 117 131 svc := service.NewContentService(cfg) 118 132 119 133 // Init Renderer 120 - renderer, err := templates.NewRenderer() 134 + renderer, err := templates.NewRenderer(cfg) 121 135 if err != nil { 122 136 slog.Error("Fatal: Could not init renderer", "error", err) 123 137 os.Exit(1)
+1
conf/config.yaml
··· 3 3 username: tumble 4 4 host: 127.0.0.1:13306 5 5 baseurl: localhost:8080 6 + mode: dev 6 7 logging: 7 8 level: debug 8 9 output: tumble.log
+2
internal/config/config.go
··· 15 15 BaseURL string `yaml:"baseurl" mapstructure:"baseurl"` 16 16 Driver string `yaml:"driver" mapstructure:"driver"` 17 17 Port string `yaml:"port" mapstructure:"port"` 18 + Mode string `yaml:"mode" mapstructure:"mode"` 18 19 Logging Logging `yaml:"logging" mapstructure:"logging"` 19 20 } 20 21 ··· 29 30 // Defaults 30 31 v.SetDefault("driver", "mysql") 31 32 v.SetDefault("port", "8080") 33 + v.SetDefault("mode", "production") 32 34 v.SetDefault("logging.level", "info") 33 35 v.SetDefault("logging.output", "stdout") 34 36
+13 -6
internal/data/factory.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 + "tumble/internal/config" 8 + 7 9 "github.com/glebarez/sqlite" 8 10 "gorm.io/driver/mysql" 9 11 "gorm.io/gorm" ··· 12 14 13 15 // NewStore creates a new Store based on the driver name and DSN. 14 16 // Driver can be "mysql" or "sqlite" (case-insensitive). 15 - func NewStore(driver, dsn string) (Store, error) { 17 + func NewStore(cfg *config.Config) (Store, error) { 16 18 var dialector gorm.Dialector 17 19 18 - switch strings.ToLower(driver) { 20 + switch strings.ToLower(cfg.Driver) { 19 21 case "mysql": 20 - dialector = mysql.Open(dsn) 22 + dialector = mysql.Open(cfg.DSN()) 21 23 case "sqlite", "sqlite3": 22 - dialector = sqlite.Open(dsn) 24 + dialector = sqlite.Open(cfg.DSN()) 23 25 default: 24 - return nil, fmt.Errorf("unknown database driver: %s", driver) 26 + return nil, fmt.Errorf("unknown database driver: %s", cfg.Driver) 27 + } 28 + 29 + logLevel := logger.Error 30 + if cfg.Mode == "development" { 31 + logLevel = logger.Info 25 32 } 26 33 27 34 db, err := gorm.Open(dialector, &gorm.Config{ 28 - Logger: logger.Default.LogMode(logger.Info), 35 + Logger: logger.Default.LogMode(logLevel), 29 36 }) 30 37 if err != nil { 31 38 return nil, err
+18 -6
internal/handler/handlers.go
··· 31 31 } 32 32 } 33 33 34 + func (h *Handler) ServerError(w http.ResponseWriter, r *http.Request, err error) { 35 + slog.Error("Internal Server Error", "method", r.Method, "path", r.URL.Path, "error", err) 36 + 37 + w.WriteHeader(http.StatusInternalServerError) 38 + 39 + if h.Config.Mode == "development" { 40 + fmt.Fprintf(w, "Internal Server Error: %s", err.Error()) 41 + } else { 42 + fmt.Fprint(w, "Internal Server Error") 43 + } 44 + } 45 + 34 46 // Index Page Data structure for the main template 35 47 type IndexPageData struct { 36 48 PageTitle string ··· 126 138 } 127 139 128 140 if errIrc != nil || errImg != nil || errQuote != nil { 129 - slog.Error("Error fetching data", "irc_error", errIrc, "img_error", errImg, "quote_error", errQuote) 130 - http.Error(w, "Internal Server Error", http.StatusInternalServerError) 141 + // Consolidate errors for logging? 142 + // Just picking one for now as example or joining them 143 + err := fmt.Errorf("irc: %v, img: %v, quote: %v", errIrc, errImg, errQuote) 144 + h.ServerError(w, r, err) 131 145 return 132 146 } 133 147 ··· 401 415 // Perform Search 402 416 links, err := h.Store.SearchIRCLinks(ctx, query) 403 417 if err != nil { 404 - slog.Error("Search error", "query", query, "error", err) 405 - http.Error(w, "Search Error", http.StatusInternalServerError) 418 + h.ServerError(w, r, err) 406 419 return 407 420 } 408 421 ··· 476 489 477 490 stats, err := h.Store.GetUserStats(ctx, sortBy, limit, offset) 478 491 if err != nil { 479 - slog.Error("Error fetching stats", "error", err) 480 - http.Error(w, "Internal Server Error", http.StatusInternalServerError) 492 + h.ServerError(w, r, err) 481 493 return 482 494 } 483 495
+51 -11
internal/templates/renderer.go
··· 7 7 "io" 8 8 "strings" 9 9 texttemplate "text/template" 10 + 11 + "tumble/internal/config" 10 12 ) 11 13 12 14 //go:embed views/*.html views/*.xml 13 15 var viewsFS embed.FS 14 16 15 17 type Renderer struct { 18 + cfg *config.Config 16 19 htmlTmpls *template.Template 17 20 xmlTmpls *texttemplate.Template 18 21 } 19 22 20 - func NewRenderer() (*Renderer, error) { 21 - htmlTmpls, err := template.ParseFS(viewsFS, "views/*.html") 22 - if err != nil { 23 - return nil, err 24 - } 23 + func NewRenderer(cfg *config.Config) (*Renderer, error) { 24 + r := &Renderer{cfg: cfg} 25 25 26 - xmlTmpls, err := texttemplate.ParseFS(viewsFS, "views/*.xml") 27 - if err != nil { 26 + // In production, parse once at startup. 27 + // In development, we can parse now too to fail early on static errors, 28 + // but Render will re-parse. 29 + if err := r.parseTemplates(); err != nil { 28 30 return nil, err 29 31 } 30 32 31 - return &Renderer{ 32 - htmlTmpls: htmlTmpls, 33 - xmlTmpls: xmlTmpls, 34 - }, nil 33 + return r, nil 34 + } 35 + 36 + func (r *Renderer) parseTemplates() error { 37 + var err error 38 + // Determine source: Embed or Filesystem 39 + if r.cfg.Mode == "development" { 40 + // Parse from local filesystem for reload 41 + // Assuming running from project root 42 + r.htmlTmpls, err = template.ParseGlob("internal/templates/views/*.html") 43 + if err != nil { 44 + return err 45 + } 46 + r.xmlTmpls, err = texttemplate.ParseGlob("internal/templates/views/*.xml") 47 + if err != nil { 48 + return err 49 + } 50 + } else { 51 + // Use embedded FS for production 52 + r.htmlTmpls, err = template.ParseFS(viewsFS, "views/*.html") 53 + if err != nil { 54 + return err 55 + } 56 + r.xmlTmpls, err = texttemplate.ParseFS(viewsFS, "views/*.xml") 57 + if err != nil { 58 + return err 59 + } 60 + } 61 + return nil 35 62 } 36 63 37 64 func (r *Renderer) Render(w io.Writer, name string, data interface{}) error { 65 + if r.cfg.Mode == "development" { 66 + // Re-parse on every request 67 + if err := r.parseTemplates(); err != nil { 68 + return err 69 + } 70 + } 71 + 38 72 if strings.HasSuffix(name, ".xml") { 39 73 return r.xmlTmpls.ExecuteTemplate(w, name, data) 40 74 } ··· 42 76 } 43 77 44 78 func (r *Renderer) RenderToString(name string, data interface{}) (string, error) { 79 + if r.cfg.Mode == "development" { 80 + if err := r.parseTemplates(); err != nil { 81 + return "", err 82 + } 83 + } 84 + 45 85 var buf bytes.Buffer 46 86 var err error 47 87 if strings.HasSuffix(name, ".xml") {