Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions buggregator.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ storage:
mode: ${STORAGE_MODE:memory} # "memory" (default, lost on restart) or "filesystem"
path: ${STORAGE_PATH:./storage} # Directory for filesystem mode

# UI event lists: how much /api/events and /api/events/preview return
ui:
default_limit: ${UI_DEFAULT_LIMIT:1000} # Events returned when the request has no limit param
max_limit: ${UI_MAX_LIMIT:5000} # Ceiling for an explicit ?limit=
default_window: ${UI_DEFAULT_WINDOW:} # Default time window, e.g. "1h", "24h", "7d". Empty = no window

# Prometheus metrics
metrics:
enabled: ${METRICS_ENABLED:false} # Set to true to expose Prometheus metrics
Expand Down
29 changes: 28 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"

"github.com/buggregator/go-buggregator/internal/auth"
Expand Down Expand Up @@ -105,7 +106,7 @@ func (a *App) Run() {
}

// Register core API routes (settings endpoint is public, others go through auth middleware).
httpserver.RegisterAPI(mux, store, a.registry.Previews(), eventService, a.cfg.Version, a.db, a.cfg.Modules.EnabledTypes(), authSettings, authMiddleware)
httpserver.RegisterAPI(mux, store, a.registry.Previews(), eventService, a.cfg.Version, a.db, a.cfg.Modules.EnabledTypes(), authSettings, authMiddleware, a.listLimits())

// Register attachment API endpoints.
httpserver.RegisterAttachmentAPI(mux, a.db, a.attachments)
Expand Down Expand Up @@ -232,3 +233,29 @@ func (a *App) Run() {
_ = srv.Shutdown(context.Background())
tcpManager.Wait()
}

// listLimits turns the ui config section into list limits. An unreadable
// default_window must not take the service down: the window is dropped and the
// reason is logged.
func (a *App) listLimits() httpserver.ListLimits {
lim := httpserver.ListLimits{
DefaultLimit: a.cfg.UI.DefaultLimit,
MaxLimit: a.cfg.UI.MaxLimit,
}

switch w := strings.TrimSpace(a.cfg.UI.DefaultWindow); w {
case "", "all", "0":
lim.DefaultWindow = 0
default:
d, err := httpserver.ParseWindow(w)
if err != nil || d <= 0 {
slog.Warn("ui.default_window is not a duration, no time window applied", "value", w, "err", err)
d = 0
}
lim.DefaultWindow = d
}

slog.Info("event list limits", "default_limit", lim.DefaultLimit,
"max_limit", lim.MaxLimit, "default_window", lim.DefaultWindow)
return lim
}
38 changes: 38 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"regexp"
"strconv"
"strings"

"gopkg.in/yaml.v3"
Expand All @@ -23,6 +24,17 @@ type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret"` // Secret for signing internal JWT tokens. Required when auth is enabled.
}

// UIConfig bounds the event lists served to the UI.
//
// /api/events and /api/events/preview used to return everything stored for a
// project. The defaults below (1000 events, no time window) only cap the
// response size; set default_window to also narrow it by time, e.g. "24h".
type UIConfig struct {
DefaultLimit int `yaml:"default_limit"` // events returned without a limit param (default 1000)
MaxLimit int `yaml:"max_limit"` // ceiling for an explicit limit (default 5000)
DefaultWindow string `yaml:"default_window"` // time window without from/to: "24h", "7d"; empty or "all" = no window
}

// Config holds application configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Expand All @@ -32,6 +44,7 @@ type Config struct {
Metrics MetricsConfig `yaml:"metrics"`
MCP MCPConfig `yaml:"mcp"`
Auth AuthConfig `yaml:"auth"`
UI UIConfig `yaml:"ui"`
Modules ModulesConfig `yaml:"modules"`
Webhooks []WebhookDef `yaml:"webhooks"`
Projects []ProjectDef `yaml:"projects"`
Expand Down Expand Up @@ -207,6 +220,11 @@ func LoadConfig() Config {
cfg.Auth.Scopes = coalesce(os.Getenv("AUTH_SCOPES"), fileCfg.Auth.Scopes, "openid,email,profile")
cfg.Auth.JWTSecret = coalesce(os.Getenv("AUTH_JWT_SECRET"), fileCfg.Auth.JWTSecret)

// UI list limits.
cfg.UI.DefaultLimit = coalesceInt(atoiOrZero(os.Getenv("UI_DEFAULT_LIMIT")), fileCfg.UI.DefaultLimit, 1000)
cfg.UI.MaxLimit = coalesceInt(atoiOrZero(os.Getenv("UI_MAX_LIMIT")), fileCfg.UI.MaxLimit, 5000)
cfg.UI.DefaultWindow = coalesce(os.Getenv("UI_DEFAULT_WINDOW"), fileCfg.UI.DefaultWindow)

// CORS origins.
cfg.Server.CORSOrigins = fileCfg.Server.CORSOrigins
if env := os.Getenv("CORS_ORIGINS"); env != "" {
Expand Down Expand Up @@ -318,6 +336,26 @@ func expandEnvVars(input string) string {
})
}

// coalesceInt is coalesce for numbers: zero counts as "not set".
func coalesceInt(values ...int) int {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}

// atoiOrZero lets an env variable be passed to coalesceInt in one expression: a
// non-empty but non-numeric value counts as "not set".
func atoiOrZero(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
}
return n
}

func coalesce(values ...string) string {
for _, v := range values {
if v != "" {
Expand Down
5 changes: 5 additions & 0 deletions internal/event/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ type FindOptions struct {
Project string
Limit int
Offset int

// From and To bound the selection by event time (unix seconds with a
// fraction, same as Event.Timestamp). Zero means "no bound".
From float64
To float64
}

// DeleteOptions configures batch deletion.
Expand Down
142 changes: 131 additions & 11 deletions internal/server/http/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"

"github.com/buggregator/go-buggregator/internal/event"
)
Expand All @@ -14,9 +17,132 @@ type AuthSettings struct {
LoginURL string
}

// ListLimits bounds what /api/events and /api/events/preview return.
//
// Both endpoints used to return every event matching type/project: Limit and
// Offset existed in FindOptions but were never filled in. On a busy project
// that is a very large response — measured on a production instance, a single
// project preview weighed 25.8 MB — which the frontend then filters in the
// browser.
type ListLimits struct {
DefaultLimit int // how many events to return when limit is absent; 0 = unlimited
MaxLimit int // ceiling for an explicit limit; 0 = no ceiling
DefaultWindow time.Duration // time window applied when neither from/to nor window is given; 0 = no window
}

// parseListOptions builds FindOptions from query parameters.
//
// type, project — unchanged;
// limit — how many events to return (capped by MaxLimit);
// offset / page — offset (page is counted from limit, 1-based);
// from, to — window bounds: unix seconds or RFC3339 ("2026-09-01T10:00:00Z");
// window — window relative to now: "24h", "15m", "7d";
// "all" or "0" opt out of DefaultWindow.
func parseListOptions(r *http.Request, lim ListLimits) event.FindOptions {
q := r.URL.Query()
opts := event.FindOptions{
Type: q.Get("type"),
Project: q.Get("project"),
Limit: lim.DefaultLimit,
}

if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
opts.Limit = n
}
}
if lim.MaxLimit > 0 && opts.Limit > lim.MaxLimit {
opts.Limit = lim.MaxLimit
}

if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
opts.Offset = n
}
} else if v := q.Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 1 {
opts.Offset = (n - 1) * opts.Limit
}
}

opts.From = parseTimeParam(q.Get("from"))
opts.To = parseTimeParam(q.Get("to"))

// The default window only applies when the caller set no bounds itself.
if opts.From == 0 && opts.To == 0 {
switch w := strings.TrimSpace(q.Get("window")); w {
case "":
if lim.DefaultWindow > 0 {
opts.From = epochSeconds(time.Now().Add(-lim.DefaultWindow))
}
case "all", "0":
// explicit opt-out
default:
if d, err := ParseWindow(w); err == nil && d > 0 {
opts.From = epochSeconds(time.Now().Add(-d))
} else if lim.DefaultWindow > 0 {
opts.From = epochSeconds(time.Now().Add(-lim.DefaultWindow))
}
}
}

return opts
}

// parseTimeParam reads a window bound: unix seconds (fraction allowed) or
// RFC3339. Returns 0 for an empty or unreadable value, which leaves the bound
// unset instead of failing the request.
func parseTimeParam(v string) float64 {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
return f
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, v); err == nil {
return epochSeconds(t)
}
}
return 0
}

// ParseWindow is time.ParseDuration plus the "d" suffix for days, which Go does
// not support but is the most common unit in a UI ("7d").
func ParseWindow(v string) (time.Duration, error) {
if strings.HasSuffix(v, "d") {
if n, err := strconv.ParseFloat(strings.TrimSuffix(v, "d"), 64); err == nil {
return time.Duration(n * float64(24*time.Hour)), nil
}
}
return time.ParseDuration(v)
}

func epochSeconds(t time.Time) float64 {
return float64(t.UnixMicro()) / 1e6
}

// listMeta reports the applied limits, so a client can tell a truncated
// response from an exhausted one.
func listMeta(opts event.FindOptions, returned int) map[string]any {
meta := map[string]any{
"limit": opts.Limit,
"offset": opts.Offset,
"returned": returned,
}
if opts.From > 0 {
meta["from"] = opts.From
}
if opts.To > 0 {
meta["to"] = opts.To
}
return meta
}

// RegisterAPI registers core API routes on the given mux.
// authMiddleware wraps protected routes; pass a no-op when auth is disabled.
func RegisterAPI(mux *http.ServeMux, store event.Store, previews *event.PreviewRegistry, es *EventService, version string, db *sql.DB, enabledEvents []string, authSettings AuthSettings, authMiddleware func(http.Handler) http.Handler) {
func RegisterAPI(mux *http.ServeMux, store event.Store, previews *event.PreviewRegistry, es *EventService, version string, db *sql.DB, enabledEvents []string, authSettings AuthSettings, authMiddleware func(http.Handler) http.Handler, listLimits ListLimits) {
// Public routes (no auth required).
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"version": version})
Expand All @@ -40,10 +166,7 @@ func RegisterAPI(mux *http.ServeMux, store event.Store, previews *event.PreviewR

// List events.
protect("GET /api/events", func(w http.ResponseWriter, r *http.Request) {
opts := event.FindOptions{
Type: r.URL.Query().Get("type"),
Project: r.URL.Query().Get("project"),
}
opts := parseListOptions(r, listLimits)
events, err := store.FindAll(r.Context(), opts)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
Expand All @@ -52,15 +175,12 @@ func RegisterAPI(mux *http.ServeMux, store event.Store, previews *event.PreviewR
if events == nil {
events = []event.Event{}
}
writeJSON(w, map[string]any{"data": events, "meta": map[string]any{}})
writeJSON(w, map[string]any{"data": events, "meta": listMeta(opts, len(events))})
})

// List event previews.
protect("GET /api/events/preview", func(w http.ResponseWriter, r *http.Request) {
opts := event.FindOptions{
Type: r.URL.Query().Get("type"),
Project: r.URL.Query().Get("project"),
}
opts := parseListOptions(r, listLimits)
events, err := store.FindAll(r.Context(), opts)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
Expand All @@ -70,7 +190,7 @@ func RegisterAPI(mux *http.ServeMux, store event.Store, previews *event.PreviewR
for _, ev := range events {
result = append(result, previews.BuildPreview(ev))
}
writeJSON(w, map[string]any{"data": result, "meta": map[string]any{}})
writeJSON(w, map[string]any{"data": result, "meta": listMeta(opts, len(result))})
})

// Get single event.
Expand Down
Loading
Loading