diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82a894b8..c217d020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,14 +69,15 @@ Do not delete half-built features that still write data (e.g. outbox enqueue) un ## Logging -Use **`sumeru/core/applog`** only: `Info` / `Warn` / `Debug` / `Error` with `Event`, or the thin helpers `InfoMsg`, `WarnMsg`, and `DebugMsg`. Before `SetupFromConfig` (config load, path resolve), use `BootstrapFatal` for fatal errors. See [docs/logging-contract.md](docs/logging-contract.md). Do not import stdlib `log` or call `fmt.Printf` for operational logging in `core/server` or `core/module`. Stdout is always on when logging is enabled; `log_file` is optional. Do not add Zap or other logging libraries. +Use **`sumeru/core/applog`** only: `Info` / `Warn` / `Debug` / `Error` with `Event`, or the thin helpers `InfoMsg`, `WarnMsg`, `DebugMsg`, **`ErrorCode`**, and **`WarnCode`**. Failures should carry a stable `error_code` (see `sumeru/core/errcode`) plus a human `message`; never log passwords, tokens, sids, or API keys (context is auto-scrubbed). Before `SetupFromConfig` (config load, path resolve), use `BootstrapFatal` for fatal errors. See the logging guide in `sumeru_docs/core/guides/logging.md`. Do not import stdlib `log` or call `fmt.Printf` for operational logging in `core/server` or `core/module`. Stdout is always on when logging is enabled; `log_file` is optional. Do not add Zap or other logging libraries. ## Testing -From the `sumeru` module root: +From the `sumeru` module root, before opening a PR: ```bash -go test ./... +make lint # swc-check + go vet + golangci-lint +go test ./test/... -count=1 go build ./... ``` @@ -86,12 +87,14 @@ Add or update tests under `test/` when you change ORM, server, or module behavio GitHub Actions runs on every pull request and push to `main` / `dev`: -| Job | What it checks | Local equivalent | -| --- | --- | --- | -| **Go build** | `go build ./...` | `go build ./...` | -| **Go test** | `go test ./... -count=1` | `go test ./...` or `make check` (with SWC) | -| **SWC** | `npm run check` + `npm run test` in `core/swc` | `make swc-test` | -| **Generate** | `make generate` — `cmd/sumeru/zimports.go` must not drift | `make generate` then review diff | +| Job | What it checks | Local equivalent | +| ------------ | --------------------------------------------------------- | ------------------------------------------------------- | +| **Go build** | `go build ./...` | `go build ./...` | +| **Go test** | `go test ./... -count=1` | `go test ./test/...` or `make check` | +| **Go lint** | `go vet` + golangci-lint | `make lint` (includes SWC typecheck) | +| **Go vuln** | `govulncheck ./...` | `go run golang.org/x/vuln/cmd/govulncheck@latest ./...` | +| **SWC** | `npm run check` + coverage tests in `core/swc` | `make swc-check` / `make swc-test` | +| **Generate** | `make generate` — `cmd/sumeru/zimports.go` must not drift | `make generate` then review diff | On **push to `main` or `dev` only**, an **integration** job boots PostgreSQL, installs the `base` module with `sumeru.conf.ci`, and runs `go test -tags=integration ./test/integration/...`. diff --git a/Makefile b/Makefile index 68e2c954..ef473db8 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,9 @@ check-sql: check-logs: @bash scripts/check_no_stdlog.sh -# Match CI go-lint: go vet + golangci-lint v2 (see .golangci.yml). +# Match CI: Go vet + golangci-lint v2 + SWC typecheck (see .golangci.yml). # Use go run so a stale v1 binary on PATH does not break the target. -lint: +lint: swc-check go vet ./... go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run --timeout=10m @@ -68,7 +68,7 @@ dev: run build: generate assets go build -o sumeru ./cmd/sumeru -check: swc-check lint test-modules-static +check: lint test-modules-static go test ./test/... -count=1 test-modules-static: @@ -133,7 +133,7 @@ help: @echo "Go / addons:" @echo " make generate - refresh cmd/sumeru/zimports.go" @echo " make bp NAME=x - scaffold kernel addon (then make generate)" - @echo " make lint - go vet + golangci-lint (matches CI go-lint)" + @echo " make lint - swc-check + go vet + golangci-lint (matches CI lint gates)" @echo " make check - swc-check + lint + test-modules-static + go test ./test/..." @echo " make test-modules - static + unit + addon module suite tiers" @echo " make test-coverage - full repo coverage with 90% gate" diff --git a/cmd/sumeru-shell/main.go b/cmd/sumeru-shell/main.go index a3b5f259..ce10972b 100644 --- a/cmd/sumeru-shell/main.go +++ b/cmd/sumeru-shell/main.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "sumeru/core/applog" "sumeru/core/orm" "sumeru/core/server/cliboot" ) @@ -56,11 +57,16 @@ func main() { } limit := 10 if len(parts) >= 3 { - limit, _ = strconv.Atoi(parts[2]) + parsed, err := strconv.Atoi(parts[2]) + if err != nil || parsed <= 0 { + fmt.Fprintln(os.Stderr, "error: invalid limit") + continue + } + limit = parsed } rows, err := orm.SearchLimit(ctx, parts[1], nil, limit) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } printJSON(rows) @@ -69,10 +75,14 @@ func main() { fmt.Println("usage: read MODEL ID") continue } - id, _ := strconv.Atoi(parts[2]) + id, err := strconv.Atoi(parts[2]) + if err != nil || id <= 0 { + fmt.Fprintln(os.Stderr, "error: invalid id") + continue + } row, err := orm.SearchOne(ctx, parts[1], map[string]interface{}{"id": id}) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } printJSON(row) @@ -83,17 +93,17 @@ func main() { } var vals map[string]interface{} if err := json.Unmarshal([]byte(strings.Join(parts[2:], " ")), &vals); err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } inst, ok := orm.Registry[parts[1]] if !ok || inst == nil { - fmt.Println("error: unknown model") + fmt.Fprintln(os.Stderr, "error: unknown model") continue } id, err := orm.Create(ctx, inst, vals) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } fmt.Println("id:", id) @@ -102,14 +112,18 @@ func main() { fmt.Println(`usage: write MODEL ID {"field":"value"}`) continue } - id, _ := strconv.Atoi(parts[2]) + id, err := strconv.Atoi(parts[2]) + if err != nil || id <= 0 { + fmt.Fprintln(os.Stderr, "error: invalid id") + continue + } var vals map[string]interface{} if err := json.Unmarshal([]byte(strings.Join(parts[3:], " ")), &vals); err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } if err := orm.UpdateRecordByID(ctx, parts[1], id, vals); err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } fmt.Println("ok") @@ -121,20 +135,23 @@ func main() { raw := strings.Join(parts[2:], " ") dom, err := orm.ParseDomainJSON(raw) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } where, args, err := orm.BuildWhereWithRecordRules(ctx, orm.SecurityUID(ctx), parts[1], "read", dom) if err != nil { - fmt.Println("error:", err) + fmt.Fprintln(os.Stderr, "error:", err) continue } fmt.Println("WHERE:", where) - fmt.Println("ARGS:", args) + printJSON(map[string]interface{}{"args": args}) default: fmt.Println("unknown command; type help") } } + if err := sc.Err(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + } } func printHelp() { @@ -173,6 +190,10 @@ func splitFields(s string) []string { } func printJSON(v interface{}) { - b, _ := json.MarshalIndent(v, "", " ") + b, err := json.MarshalIndent(applog.ScrubValue("", v), "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return + } fmt.Println(string(b)) } diff --git a/core/applog/event.go b/core/applog/event.go index 3f7662c1..ee675c88 100644 --- a/core/applog/event.go +++ b/core/applog/event.go @@ -10,6 +10,7 @@ import ( // Top-level fields are universal; module-specific data belongs in Context. type Event struct { Message string + Code string // stable machine id (SCREAMING_SNAKE); emitted as error_code Component string Module string Operation string @@ -51,7 +52,12 @@ func mergeEventContext(ctx context.Context, ev Event) map[string]interface{} { if ev.Err != nil { out["error"] = ev.Err.Error() } - return out + if ev.Code != "" { + if _, ok := out["error_code"]; !ok { + out["error_code"] = ev.Code + } + } + return ScrubMap(out) } func emit(ctx context.Context, level slog.Level, ev Event) { @@ -67,6 +73,9 @@ func emit(ctx context.Context, level slog.Level, ev Event) { slog.String("operation", ev.Operation), slog.String("status", ev.Status), } + if ev.Code != "" { + attrs = append(attrs, slog.String("error_code", ev.Code)) + } if ev.Module != "" { attrs = append(attrs, slog.String("module", ev.Module)) } diff --git a/core/applog/logger.go b/core/applog/logger.go index ab65c154..698d38da 100644 --- a/core/applog/logger.go +++ b/core/applog/logger.go @@ -6,6 +6,8 @@ import ( "os" "sync" "time" + + "sumeru/core/errcode" ) var ( @@ -56,12 +58,16 @@ func L(ctx context.Context) *slog.Logger { // Fatal logs at error level and exits the process. func Fatal(ctx context.Context, msg string, keysAndValues ...interface{}) { - attrs := keysAndValues - if len(attrs) == 0 { - Error(ctx, Event{Message: msg, Component: "server", Status: "failure"}) - } else { - Error(ctx, Event{Message: msg, Component: "server", Status: "failure", Context: kvPairsToMap(attrs)}) + ev := Event{ + Message: msg, + Code: errcode.InternalError, + Component: "server", + Status: "failure", + } + if len(keysAndValues) > 0 { + ev.Context = kvPairsToMap(keysAndValues) } + Error(ctx, ev) os.Exit(1) } diff --git a/core/applog/scrub.go b/core/applog/scrub.go new file mode 100644 index 00000000..92041ae5 --- /dev/null +++ b/core/applog/scrub.go @@ -0,0 +1,71 @@ +package applog + +import "strings" + +const RedactedPlaceholder = "***" + +func ScrubMap(fields map[string]interface{}) map[string]interface{} { + if fields == nil { + return nil + } + scrubbed := make(map[string]interface{}, len(fields)) + for fieldName, value := range fields { + scrubbed[fieldName] = ScrubValue(fieldName, value) + } + return scrubbed +} + +func ScrubValue(fieldName string, value any) any { + if IsSecretKey(fieldName) { + return RedactedPlaceholder + } + switch typed := value.(type) { + case map[string]interface{}: + return ScrubMap(typed) + case map[string]string: + scrubbed := make(map[string]interface{}, len(typed)) + for nestedName, nestedValue := range typed { + scrubbed[nestedName] = ScrubValue(nestedName, nestedValue) + } + return scrubbed + case []map[string]interface{}: + scrubbed := make([]interface{}, len(typed)) + for i := range typed { + scrubbed[i] = ScrubMap(typed[i]) + } + return scrubbed + case []interface{}: + scrubbed := make([]interface{}, len(typed)) + for i := range typed { + scrubbed[i] = ScrubValue(fieldName, typed[i]) + } + return scrubbed + default: + return value + } +} + +// IsSecretKey: "sid" is exact-match only (avoids "inside"). +func IsSecretKey(fieldName string) bool { + normalized := strings.ToLower(strings.TrimSpace(fieldName)) + return normalized == "sid" || (normalized != "" && containsSecretKeyword(normalized)) +} + +func TextContainsSecretKeyword(text string) bool { + lowered := strings.ToLower(text) + return strings.Contains(lowered, "sid") || containsSecretKeyword(lowered) +} + +func containsSecretKeyword(haystack string) bool { + for _, keyword := range secretKeywords { + if strings.Contains(haystack, keyword) { + return true + } + } + return false +} + +var secretKeywords = []string{ + "password", "token", "secret", "authorization", "cookie", + "session", "api_key", "apikey", "key_hash", "csrf", "totp", +} diff --git a/core/applog/stdio.go b/core/applog/stdio.go index 8f7c97ee..9b2c2f6f 100644 --- a/core/applog/stdio.go +++ b/core/applog/stdio.go @@ -2,36 +2,45 @@ package applog import "context" -// InfoMsg logs a structured info event with the logging contract. func InfoMsg(ctx context.Context, component, operation, message string, ctxFields map[string]interface{}) { Info(ctx, Event{ - Message: message, - Component: component, - Operation: operation, - Status: "success", - Context: ctxFields, + Message: message, Component: component, Operation: operation, + Status: "success", Context: ctxFields, }) } -// WarnMsg logs a structured warning event with the logging contract. func WarnMsg(ctx context.Context, component, operation, message string, err error, ctxFields map[string]interface{}) { Warn(ctx, Event{ - Message: message, - Component: component, - Operation: operation, - Status: "partial", - Context: ctxFields, - Err: err, + Message: message, Component: component, Operation: operation, + Status: "partial", Context: ctxFields, Err: err, }) } -// DebugMsg logs a structured debug event with the logging contract. func DebugMsg(ctx context.Context, component, operation, message string, ctxFields map[string]interface{}) { Debug(ctx, Event{ - Message: message, - Component: component, - Operation: operation, - Status: "success", - Context: ctxFields, + Message: message, Component: component, Operation: operation, + Status: "success", Context: ctxFields, }) } + +func ErrorCode(ctx context.Context, code, message string, ev Event) { + ev.Code = code + if message != "" { + ev.Message = message + } + if ev.Status == "" { + ev.Status = "failure" + } + Error(ctx, ev) +} + +func WarnCode(ctx context.Context, code, message string, ev Event) { + ev.Code = code + if message != "" { + ev.Message = message + } + if ev.Status == "" { + ev.Status = "partial" + } + Warn(ctx, ev) +} diff --git a/core/engine/assets/css/sumeru-apps.css b/core/engine/assets/css/sumeru-apps.css index 0e3ee5d9..8b97daa4 100644 --- a/core/engine/assets/css/sumeru-apps.css +++ b/core/engine/assets/css/sumeru-apps.css @@ -1222,18 +1222,6 @@ padding: 1.25rem; } -.sum-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - .sum-apps-categories { border: 1px solid var(--sum-line-strong); border-radius: var(--sum-radius-sm); diff --git a/core/engine/assets/css/sumeru-base.css b/core/engine/assets/css/sumeru-base.css index 141d3ce7..6cc802ff 100644 --- a/core/engine/assets/css/sumeru-base.css +++ b/core/engine/assets/css/sumeru-base.css @@ -34,8 +34,10 @@ input[type="number"] { background: rgba(0, 0, 0, 0.2); } -/* Visually hidden labels (screen readers only). */ -.sr-only { +/* Visually hidden (screen readers only). Prefer .sum-visually-hidden; aliases kept for existing markup. */ +.sr-only, +.visually-hidden, +.sum-visually-hidden { position: absolute; width: 1px; height: 1px; diff --git a/core/engine/assets/css/sumeru-home.css b/core/engine/assets/css/sumeru-home.css index a9bc89cf..04dc18e8 100644 --- a/core/engine/assets/css/sumeru-home.css +++ b/core/engine/assets/css/sumeru-home.css @@ -368,15 +368,3 @@ text-overflow: ellipsis; white-space: nowrap; } - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} diff --git a/core/engine/assets/css/sumeru-shell.css b/core/engine/assets/css/sumeru-shell.css index cd7e9195..785bf6af 100644 --- a/core/engine/assets/css/sumeru-shell.css +++ b/core/engine/assets/css/sumeru-shell.css @@ -125,21 +125,21 @@ background: var(--sum-topbar-hover-bg); } -/* Sidebar toggle (hamburger): stroke follows currentColor; pin white for contrast */ +/* Sidebar toggle (hamburger): stroke follows currentColor via topbar fg */ .sum-topbar-sidebar-toggle { - color: #ffffff !important; + color: var(--sum-topbar-fg); } .sum-topbar-sidebar-toggle svg, .sum-topbar-sidebar-toggle svg path { - stroke: #ffffff !important; + stroke: currentColor; } .sum-topbar-sidebar-toggle:hover svg, .sum-topbar-sidebar-toggle:hover svg path, .sum-topbar-sidebar-toggle:focus-visible svg, .sum-topbar-sidebar-toggle:focus-visible svg path { - stroke: #ffffff !important; + stroke: currentColor; } .sum-topbar-right { diff --git a/core/engine/assets/css/sumeru-theme.css b/core/engine/assets/css/sumeru-theme.css index 96372770..cfcfedf2 100644 --- a/core/engine/assets/css/sumeru-theme.css +++ b/core/engine/assets/css/sumeru-theme.css @@ -9,6 +9,20 @@ --sum-gold: #f0ad4e; --sum-gold-soft: rgba(240, 173, 78, 0.22); + /* —— Stage / kanban palette (shared) —— */ + --sum-stage-0: #875a7b; + --sum-stage-1: #9c9c9c; + --sum-stage-2: #67917a; + --sum-stage-3: #a06666; + --sum-stage-4: #8899aa; + --sum-stage-5: #d4a017; + --sum-stage-6: #6a5acd; + --sum-stage-7: #4682b4; + --sum-stage-8: #c0392b; + --sum-stage-9: #e67e22; + --sum-stage-10: #27ae60; + --sum-stage-11: #3498db; + /* —— Semantic —— */ --sum-primary: var(--sum-header); --sum-primary-hover: var(--sum-header-dark); diff --git a/core/engine/assets/css/sumeru-views.css b/core/engine/assets/css/sumeru-views.css index 28ec8a51..69b6478d 100644 --- a/core/engine/assets/css/sumeru-views.css +++ b/core/engine/assets/css/sumeru-views.css @@ -729,18 +729,18 @@ color: var(--sum-ink); } -.sum-statusbar-stage--current.sum-statusbar-stage--color-0 { border-color: #875a7b; color: #875a7b; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-1 { border-color: #9c9c9c; color: #666; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-2 { border-color: #67917a; color: #67917a; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-3 { border-color: #a06666; color: #a06666; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-4 { border-color: #8899aa; color: #556677; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-5 { border-color: #d4a017; color: #b8860b; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-6 { border-color: #6a5acd; color: #6a5acd; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-7 { border-color: #4682b4; color: #4682b4; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-8 { border-color: #c0392b; color: #c0392b; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-9 { border-color: #e67e22; color: #e67e22; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-10 { border-color: #27ae60; color: #27ae60; } -.sum-statusbar-stage--current.sum-statusbar-stage--color-11 { border-color: #3498db; color: #3498db; } +.sum-statusbar-stage--current.sum-statusbar-stage--color-0 { border-color: var(--sum-stage-0); color: var(--sum-stage-0); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-1 { border-color: var(--sum-stage-1); color: #666; } +.sum-statusbar-stage--current.sum-statusbar-stage--color-2 { border-color: var(--sum-stage-2); color: var(--sum-stage-2); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-3 { border-color: var(--sum-stage-3); color: var(--sum-stage-3); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-4 { border-color: var(--sum-stage-4); color: #556677; } +.sum-statusbar-stage--current.sum-statusbar-stage--color-5 { border-color: var(--sum-stage-5); color: #b8860b; } +.sum-statusbar-stage--current.sum-statusbar-stage--color-6 { border-color: var(--sum-stage-6); color: var(--sum-stage-6); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-7 { border-color: var(--sum-stage-7); color: var(--sum-stage-7); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-8 { border-color: var(--sum-stage-8); color: var(--sum-stage-8); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-9 { border-color: var(--sum-stage-9); color: var(--sum-stage-9); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-10 { border-color: var(--sum-stage-10); color: var(--sum-stage-10); } +.sum-statusbar-stage--current.sum-statusbar-stage--color-11 { border-color: var(--sum-stage-11); color: var(--sum-stage-11); } .sum-priority-stars { display: inline-flex; diff --git a/core/engine/assets/css/sumeru-workspace.css b/core/engine/assets/css/sumeru-workspace.css index d0a82b3e..3b9a1c3b 100644 --- a/core/engine/assets/css/sumeru-workspace.css +++ b/core/engine/assets/css/sumeru-workspace.css @@ -1248,18 +1248,18 @@ background: transparent; } -.sum-kanban-card-stripe--color-0 { background: #875a7b; } -.sum-kanban-card-stripe--color-1 { background: #9c9c9c; } -.sum-kanban-card-stripe--color-2 { background: #67917a; } -.sum-kanban-card-stripe--color-3 { background: #a06666; } -.sum-kanban-card-stripe--color-4 { background: #8899aa; } -.sum-kanban-card-stripe--color-5 { background: #d4a017; } -.sum-kanban-card-stripe--color-6 { background: #6a5acd; } -.sum-kanban-card-stripe--color-7 { background: #4682b4; } -.sum-kanban-card-stripe--color-8 { background: #c0392b; } -.sum-kanban-card-stripe--color-9 { background: #e67e22; } -.sum-kanban-card-stripe--color-10 { background: #27ae60; } -.sum-kanban-card-stripe--color-11 { background: #3498db; } +.sum-kanban-card-stripe--color-0 { background: var(--sum-stage-0); } +.sum-kanban-card-stripe--color-1 { background: var(--sum-stage-1); } +.sum-kanban-card-stripe--color-2 { background: var(--sum-stage-2); } +.sum-kanban-card-stripe--color-3 { background: var(--sum-stage-3); } +.sum-kanban-card-stripe--color-4 { background: var(--sum-stage-4); } +.sum-kanban-card-stripe--color-5 { background: var(--sum-stage-5); } +.sum-kanban-card-stripe--color-6 { background: var(--sum-stage-6); } +.sum-kanban-card-stripe--color-7 { background: var(--sum-stage-7); } +.sum-kanban-card-stripe--color-8 { background: var(--sum-stage-8); } +.sum-kanban-card-stripe--color-9 { background: var(--sum-stage-9); } +.sum-kanban-card-stripe--color-10 { background: var(--sum-stage-10); } +.sum-kanban-card-stripe--color-11 { background: var(--sum-stage-11); } .sum-kanban-card-content { position: relative; @@ -1403,18 +1403,18 @@ border-top: 3px solid var(--sum-line-strong); } -.sum-kanban-stage-header--color-0 { border-top-color: #875a7b; } -.sum-kanban-stage-header--color-1 { border-top-color: #9c9c9c; } -.sum-kanban-stage-header--color-2 { border-top-color: #67917a; } -.sum-kanban-stage-header--color-3 { border-top-color: #a06666; } -.sum-kanban-stage-header--color-4 { border-top-color: #8899aa; } -.sum-kanban-stage-header--color-5 { border-top-color: #d4a017; } -.sum-kanban-stage-header--color-6 { border-top-color: #6a5acd; } -.sum-kanban-stage-header--color-7 { border-top-color: #4682b4; } -.sum-kanban-stage-header--color-8 { border-top-color: #c0392b; } -.sum-kanban-stage-header--color-9 { border-top-color: #e67e22; } -.sum-kanban-stage-header--color-10 { border-top-color: #27ae60; } -.sum-kanban-stage-header--color-11 { border-top-color: #3498db; } +.sum-kanban-stage-header--color-0 { border-top-color: var(--sum-stage-0); } +.sum-kanban-stage-header--color-1 { border-top-color: var(--sum-stage-1); } +.sum-kanban-stage-header--color-2 { border-top-color: var(--sum-stage-2); } +.sum-kanban-stage-header--color-3 { border-top-color: var(--sum-stage-3); } +.sum-kanban-stage-header--color-4 { border-top-color: var(--sum-stage-4); } +.sum-kanban-stage-header--color-5 { border-top-color: var(--sum-stage-5); } +.sum-kanban-stage-header--color-6 { border-top-color: var(--sum-stage-6); } +.sum-kanban-stage-header--color-7 { border-top-color: var(--sum-stage-7); } +.sum-kanban-stage-header--color-8 { border-top-color: var(--sum-stage-8); } +.sum-kanban-stage-header--color-9 { border-top-color: var(--sum-stage-9); } +.sum-kanban-stage-header--color-10 { border-top-color: var(--sum-stage-10); } +.sum-kanban-stage-header--color-11 { border-top-color: var(--sum-stage-11); } .sum-kanban-card-activity { margin-top: 0.35rem; @@ -1496,18 +1496,18 @@ cursor: pointer; } -.sum-kanban-color-swatch--0 { background: #875a7b; } -.sum-kanban-color-swatch--1 { background: #9c9c9c; } -.sum-kanban-color-swatch--2 { background: #67917a; } -.sum-kanban-color-swatch--3 { background: #a06666; } -.sum-kanban-color-swatch--4 { background: #8899aa; } -.sum-kanban-color-swatch--5 { background: #d4a017; } -.sum-kanban-color-swatch--6 { background: #6a5acd; } -.sum-kanban-color-swatch--7 { background: #4682b4; } -.sum-kanban-color-swatch--8 { background: #c0392b; } -.sum-kanban-color-swatch--9 { background: #e67e22; } -.sum-kanban-color-swatch--10 { background: #27ae60; } -.sum-kanban-color-swatch--11 { background: #3498db; } +.sum-kanban-color-swatch--0 { background: var(--sum-stage-0); } +.sum-kanban-color-swatch--1 { background: var(--sum-stage-1); } +.sum-kanban-color-swatch--2 { background: var(--sum-stage-2); } +.sum-kanban-color-swatch--3 { background: var(--sum-stage-3); } +.sum-kanban-color-swatch--4 { background: var(--sum-stage-4); } +.sum-kanban-color-swatch--5 { background: var(--sum-stage-5); } +.sum-kanban-color-swatch--6 { background: var(--sum-stage-6); } +.sum-kanban-color-swatch--7 { background: var(--sum-stage-7); } +.sum-kanban-color-swatch--8 { background: var(--sum-stage-8); } +.sum-kanban-color-swatch--9 { background: var(--sum-stage-9); } +.sum-kanban-color-swatch--10 { background: var(--sum-stage-10); } +.sum-kanban-color-swatch--11 { background: var(--sum-stage-11); } .sum-kanban-color-clear { grid-column: 1 / -1; diff --git a/core/errcode/codes.go b/core/errcode/codes.go new file mode 100644 index 00000000..5f44b5d4 --- /dev/null +++ b/core/errcode/codes.go @@ -0,0 +1,21 @@ +package errcode + +// Stable machine identifiers for structured logs (and optional HTTP/RPC). +// Prefer these string values when emitting applog.Event.Code. +// RPC API keeps aliases in sumeru/core/server/api for the same literals. +const ( + Unauthorized = "UNAUTHORIZED" + SessionExpired = "SESSION_EXPIRED" + InvalidCredentials = "INVALID_CREDENTIALS" + ValidationError = "VALIDATION_ERROR" + EmailAlreadyExists = "EMAIL_ALREADY_EXISTS" + AccessDenied = "ACCESS_DENIED" + RecordNotFound = "RECORD_NOT_FOUND" + NotFound = "NOT_FOUND" + InternalError = "INTERNAL_ERROR" + SyncPartial = "SYNC_PARTIAL" + XMLLinkFailed = "XML_LINK_FAILED" + CronUpdateFailed = "CRON_UPDATE_FAILED" + CronCommitFailed = "CRON_COMMIT_FAILED" + CronHandlerFailed = "CRON_HANDLER_FAILED" +) diff --git a/core/module/data_sync_csv.go b/core/module/data_sync_csv.go index 31654b9d..2704eecc 100644 --- a/core/module/data_sync_csv.go +++ b/core/module/data_sync_csv.go @@ -80,7 +80,9 @@ func (addon *Addon) syncCSVModelAccess(ctx context.Context) error { syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.access", recordXmlId, err) continue } - _ = linkXMLRecord(ctx, moduleName, recordXmlId, "sys.access", id) + if err := linkXMLRecord(ctx, moduleName, recordXmlId, "sys.access", id); err != nil { + continue + } } return nil } diff --git a/core/module/data_sync_menus.go b/core/module/data_sync_menus.go index 57e334e1..9e5f8b73 100644 --- a/core/module/data_sync_menus.go +++ b/core/module/data_sync_menus.go @@ -99,7 +99,9 @@ func upsertMenuRow(ctx context.Context, moduleName, xmlID string, menuValues map } rowID = id } - _ = linkXMLRecord(ctx, moduleName, xmlID, "sys.menu", rowID) + if err := linkXMLRecord(ctx, moduleName, xmlID, "sys.menu", rowID); err != nil { + return 0, err + } return rowID, nil } diff --git a/core/module/data_sync_records.go b/core/module/data_sync_records.go index 1911a43f..8750b7d8 100644 --- a/core/module/data_sync_records.go +++ b/core/module/data_sync_records.go @@ -119,7 +119,9 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, xmlRecord.Model, xmlRecord.ID, err) return } - _ = linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, int(eid)) + if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, int(eid)); err != nil { + return + } return } } @@ -157,7 +159,9 @@ func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord } } } - _ = linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, id) + if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, xmlRecord.Model, id); err != nil { + return + } } // ConvertRecordScalar coerces XML/form string values into types used for registry upserts. diff --git a/core/module/data_sync_views.go b/core/module/data_sync_views.go index 44c531e8..762f02f1 100644 --- a/core/module/data_sync_views.go +++ b/core/module/data_sync_views.go @@ -38,7 +38,9 @@ func upsertSysActionWindowFromRecord(ctx context.Context, moduleName string, xml syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.action.window", xmlRecord.ID, err) return } - _ = linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.action.window", id) + if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.action.window", id); err != nil { + return + } } func upsertSysActionURLFromRecord(ctx context.Context, moduleName string, xmlRecord parser.Record) { @@ -55,7 +57,9 @@ func upsertSysActionURLFromRecord(ctx context.Context, moduleName string, xmlRec syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.action.url", xmlRecord.ID, err) return } - _ = linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.action.url", id) + if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.action.url", id); err != nil { + return + } } // upsertSysViewFromRecord persists data (non-inherit rows). @@ -92,7 +96,9 @@ func upsertSysViewFromRecord(ctx context.Context, moduleName string, xmlRecord p syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.view", xmlRecord.ID, err) return } - _ = linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.view", id) + if err := linkXMLRecord(ctx, moduleName, xmlRecord.ID, "sys.view", id); err != nil { + return + } } func InferSysViewTypeFromArch(arch string) string { @@ -148,7 +154,9 @@ func upsertInlineViewDef(ctx context.Context, moduleName string, viewDef *parser syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.view", viewDef.ID, err) return } - _ = linkXMLRecord(ctx, moduleName, viewDef.ID, "sys.view", id) + if err := linkXMLRecord(ctx, moduleName, viewDef.ID, "sys.view", id); err != nil { + return + } } // applySysUIViewInherit merges an sys.view inherit into the parent view row (same DB id). diff --git a/core/module/discovery.go b/core/module/discovery.go index 1e25b79b..ccdfbf3d 100644 --- a/core/module/discovery.go +++ b/core/module/discovery.go @@ -9,6 +9,7 @@ import ( "strings" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/orm" ) @@ -64,13 +65,23 @@ func LoadAddonPaths(rootPaths []string) error { syncErr := addon.SyncToDB(contextWithBypass) if fatal := recordSyncToDBResult(contextWithBypass, addon.Manifest.Name, syncErr); fatal != nil { syncErrs = append(syncErrs, fatal) - applog.WarnMsg(contextWithBypass, "module", "sync", - fmt.Sprintf("Error syncing addon %s", addon.Manifest.Name), fatal, nil) + applog.WarnCode(contextWithBypass, errcode.SyncPartial, fmt.Sprintf("Error syncing addon %s", addon.Manifest.Name), applog.Event{ + Component: "module", + Operation: "sync", + Status: "partial", + Context: map[string]interface{}{"addon": addon.Manifest.Name}, + Err: fatal, + }) continue } if syncErr != nil { - applog.WarnMsg(contextWithBypass, "module", "sync", - fmt.Sprintf("Error syncing addon %s", addon.Manifest.Name), syncErr, nil) + applog.WarnCode(contextWithBypass, errcode.SyncPartial, fmt.Sprintf("Error syncing addon %s", addon.Manifest.Name), applog.Event{ + Component: "module", + Operation: "sync", + Status: "partial", + Context: map[string]interface{}{"addon": addon.Manifest.Name}, + Err: syncErr, + }) } else { applog.InfoMsg(contextWithBypass, "module", "sync", fmt.Sprintf("Loaded addon data: %s (v%s)", addon.Manifest.Name, addon.Manifest.Version), nil) diff --git a/core/module/xmlid_link.go b/core/module/xmlid_link.go index 0817aa1d..16fa4eed 100644 --- a/core/module/xmlid_link.go +++ b/core/module/xmlid_link.go @@ -6,6 +6,7 @@ import ( "strings" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/orm" ) @@ -43,21 +44,23 @@ func linkXMLRecord(ctx context.Context, moduleName, xmlID, model string, coreID "core_id": coreID, }, "name") if err != nil { - syncWarn(ctx, "Failed to link XML record %s.%s (%s id=%d): %v", moduleName, xmlID, model, coreID, err) + syncWarnCode(ctx, errcode.XMLLinkFailed, "Failed to link XML record %s.%s (%s id=%d): %v", moduleName, xmlID, model, coreID, err) } return err } func syncWarn(ctx context.Context, format string, args ...interface{}) { + syncWarnCode(ctx, errcode.SyncPartial, format, args...) +} + +func syncWarnCode(ctx context.Context, code, format string, args ...interface{}) { if ctx == nil { ctx = context.Background() } msg := fmt.Sprintf(format, args...) - applog.Warn(ctx, applog.Event{ - Message: msg, + applog.WarnCode(ctx, code, msg, applog.Event{ Component: "module", Operation: "sync", Status: "partial", - Context: map[string]interface{}{"detail": msg}, }) } diff --git a/core/orm/audit.go b/core/orm/audit.go index 3b2df67d..22ffab65 100644 --- a/core/orm/audit.go +++ b/core/orm/audit.go @@ -7,6 +7,7 @@ import ( "time" "sumeru/core/applog" + "sumeru/core/errcode" ) func skipAuditModel(model string) bool { @@ -22,32 +23,10 @@ func auditValues(ctx context.Context, action, model string, resID int64, before, uid := SecurityUID(ctx) var beforeJSON, afterJSON string if before != nil { - if b, err := json.Marshal(scrubAuditMap(before)); err == nil { - beforeJSON = string(b) - } else { - applog.Warn(ctx, applog.Event{ - Message: "Audit before_json marshal failed", - Component: "orm", - Operation: "audit", - Status: "partial", - Context: map[string]interface{}{"resource": model, "resource_id": resID}, - Err: err, - }) - } + beforeJSON = marshalAuditJSON(ctx, "before_json", model, resID, before) } if after != nil { - if b, err := json.Marshal(scrubAuditMap(after)); err == nil { - afterJSON = string(b) - } else { - applog.Warn(ctx, applog.Event{ - Message: "Audit after_json marshal failed", - Component: "orm", - Operation: "audit", - Status: "partial", - Context: map[string]interface{}{"resource": model, "resource_id": resID}, - Err: err, - }) - } + afterJSON = marshalAuditJSON(ctx, "after_json", model, resID, after) } vals := map[string]interface{}{ "action": action, @@ -64,6 +43,21 @@ func auditValues(ctx context.Context, action, model string, resID int64, before, return vals } +func marshalAuditJSON(ctx context.Context, field, model string, resID int64, values map[string]interface{}) string { + b, err := json.Marshal(scrubAuditMap(values)) + if err != nil { + applog.WarnCode(ctx, errcode.InternalError, "Audit "+field+" marshal failed", applog.Event{ + Component: "orm", + Operation: "audit", + Status: "partial", + Context: map[string]interface{}{"resource": model, "resource_id": resID}, + Err: err, + }) + return "" + } + return string(b) +} + // AppendAudit writes an immutable audit row (best-effort; never fails the caller). func AppendAudit(ctx context.Context, action, model string, resID int64, before, after map[string]interface{}, detail string) { AppendAuditTx(ctx, nil, action, model, resID, before, after, detail) @@ -79,16 +73,7 @@ func AppendAuditTx(ctx context.Context, tx TxWrapper, action, model string, resI } func scrubAuditMap(m map[string]interface{}) map[string]interface{} { - out := make(map[string]interface{}, len(m)) - for k, v := range m { - lk := strings.ToLower(k) - if lk == "password" || lk == "key_hash" || strings.Contains(lk, "password") { - out[k] = "***" - continue - } - out[k] = v - } - return out + return applog.ScrubMap(m) } // LogAccessDeny records a permission denial in sys.audit. diff --git a/core/orm/classify_log_code.go b/core/orm/classify_log_code.go new file mode 100644 index 00000000..2f7233a4 --- /dev/null +++ b/core/orm/classify_log_code.go @@ -0,0 +1,31 @@ +package orm + +import ( + "errors" + "strings" + + "sumeru/core/errcode" +) + +// ClassifyLogCode maps an error to a stable applog/errcode machine id. +func ClassifyLogCode(err error) string { + if err == nil { + return errcode.InternalError + } + if IsAccessDenied(err) || IsRecordRuleFailed(err) { + return errcode.AccessDenied + } + var fieldErr *FieldValidationError + if errors.As(err, &fieldErr) { + return errcode.ValidationError + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "record(s) not found"), strings.Contains(msg, "not found"): + return errcode.RecordNotFound + case strings.Contains(msg, "validation"), strings.Contains(msg, "invalid"), strings.Contains(msg, "required"): + return errcode.ValidationError + default: + return errcode.InternalError + } +} diff --git a/core/orm/crud_search.go b/core/orm/crud_search.go index aa4204a9..22d43e13 100644 --- a/core/orm/crud_search.go +++ b/core/orm/crud_search.go @@ -92,7 +92,7 @@ func Search(ctx context.Context, modelName string, domain [][]interface{}) (resu if results != nil { n = len(results) } - logORMOperationKV(ctx, start, "search", modelName, err, "rows", n) + logORMOperation(ctx, start, "search", modelName, err, map[string]interface{}{"rows": n}) }() results, err = execSearchQuery(ctx, modelName, domain, nil) if err != nil { @@ -124,7 +124,7 @@ func SearchPage(ctx context.Context, modelName string, domain [][]interface{}, l if results != nil { n = len(results) } - logORMOperationKV(ctx, start, "search_page", modelName, err, "rows", n, "limit", limit, "offset", offset) + logORMOperation(ctx, start, "search_page", modelName, err, map[string]interface{}{"rows": n, "limit": limit, "offset": offset}) }() if limit <= 0 || limit > maxSearchLimit { limit = maxSearchLimit @@ -150,7 +150,7 @@ func SearchPage(ctx context.Context, modelName string, domain [][]interface{}, l func SearchCount(ctx context.Context, modelName string, domain [][]interface{}) (n int, err error) { start := time.Now() defer func() { - logORMOperationKV(ctx, start, "search_count", modelName, err, "count", n) + logORMOperation(ctx, start, "search_count", modelName, err, map[string]interface{}{"count": n}) }() ctx = ContextWithReadReplica(ctx, true) _, whereClause, args, _, err := prepareSearchRead(ctx, modelName, domain) diff --git a/core/orm/crud_search_one.go b/core/orm/crud_search_one.go index 2d2b524d..72d363cd 100644 --- a/core/orm/crud_search_one.go +++ b/core/orm/crud_search_one.go @@ -31,7 +31,7 @@ func CriteriaToDomain(criteria map[string]interface{}) [][]interface{} { func SearchOne(ctx context.Context, modelName string, criteria map[string]interface{}) (result map[string]interface{}, err error) { start := time.Now() defer func() { - logORMOperationKV(ctx, start, "search_one", modelName, err, "has_row", result != nil) + logORMOperation(ctx, start, "search_one", modelName, err, map[string]interface{}{"has_row": result != nil}) }() if _, ok := Registry[modelName]; !ok { return nil, fmt.Errorf("model %s not registered", modelName) diff --git a/core/orm/crud_sideeffects.go b/core/orm/crud_sideeffects.go index 2fa0eb92..326514ad 100644 --- a/core/orm/crud_sideeffects.go +++ b/core/orm/crud_sideeffects.go @@ -5,6 +5,7 @@ import ( "fmt" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/event" ) @@ -27,10 +28,12 @@ func emitSideEffectsOnTx(ctx context.Context, tx TxWrapper, modelName string, ui } for _, row := range rows { AppendAuditTx(ctx, tx, row.Action, modelName, row.ResID, row.Before, row.After, row.Detail) - EnqueueOutboxTx(ctx, tx, row.EventName, uid, map[string]interface{}{ + if err := EnqueueOutboxTx(ctx, tx, row.EventName, uid, map[string]interface{}{ "model": modelName, "id": int(row.ResID), - }) + }); err != nil { + logSideEffectWarn(ctx, "outbox_enqueue", modelName, err, "event", row.EventName) + } } } @@ -56,8 +59,7 @@ func logSideEffectWarn(ctx context.Context, operation, modelName string, err err ctxMap[k] = extra[i+1] } } - applog.Warn(ctx, applog.Event{ - Message: operation + " side effect failed for " + modelName, + applog.WarnCode(ctx, errcode.InternalError, operation+" side effect failed for "+modelName, applog.Event{ Component: "orm", Module: DeclaringModule(modelName), Operation: operation, diff --git a/core/orm/crud_tx.go b/core/orm/crud_tx.go index 3bd74f09..f268ec37 100644 --- a/core/orm/crud_tx.go +++ b/core/orm/crud_tx.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "strings" - - "sumeru/core/applog" ) func insertPreparedOnTx(ctx context.Context, tx TxWrapper, model Model, prepared map[string]interface{}) (int, error) { @@ -105,14 +103,7 @@ func insertSideEffectRow(ctx context.Context, tx TxWrapper, registryKey string, } _, err := Create(bypass, inst, vals) if err != nil { - applog.Warn(ctx, applog.Event{ - Message: "Side effect insert failed", - Component: "orm", - Operation: "insert_side_effect", - Status: "partial", - Context: map[string]interface{}{"resource": registryKey}, - Err: err, - }) + logSideEffectWarn(ctx, "insert_side_effect", registryKey, err) } return err } diff --git a/core/orm/db_dev_log.go b/core/orm/db_dev_log.go index 32cb065e..12efc274 100644 --- a/core/orm/db_dev_log.go +++ b/core/orm/db_dev_log.go @@ -6,7 +6,7 @@ import ( "strings" "time" - applog "sumeru/core/applog" + "sumeru/core/applog" ) type loggingDBWrapper struct { @@ -20,71 +20,68 @@ func wrapDevLogging(db DBWrapper) DBWrapper { return &loggingDBWrapper{inner: db} } -type sqlLogEntry struct { - Op string - Query string - Args []interface{} - Err error - Dur time.Duration -} - -func logSQL(ctx context.Context, in sqlLogEntry) { - if !DevFeatureEnabled("sql") { - return - } - q := strings.Join(strings.Fields(in.Query), " ") +func logSQL(ctx context.Context, op, query string, args []interface{}, err error, dur time.Duration) { + q := strings.Join(strings.Fields(query), " ") if len(q) > 500 { q = q[:500] + "…" } - fields := []interface{}{"op", in.Op, "sql", q, "ms", in.Dur.Milliseconds()} - if len(in.Args) > 0 { - fields = append(fields, "args", in.Args) + ctxMap := map[string]interface{}{"op": op, "sql": q, "ms": dur.Milliseconds()} + if len(args) > 0 { + ctxMap["args"] = scrubSQLArgs(query, args) } - if in.Err != nil { - fields = append(fields, "err", in.Err.Error()) + status := "success" + if err != nil { + status = "failure" } - applog.L(ctx).Debug("dev_sql", fields...) + applog.Debug(ctx, applog.Event{ + Message: "SQL", Component: "orm", Operation: "sql", Status: status, Context: ctxMap, Err: err, + }) +} + +func scrubSQLArgs(query string, args []interface{}) interface{} { + if len(args) == 0 { + return nil + } + if applog.TextContainsSecretKeyword(query) { + return applog.RedactedPlaceholder + } + out := make([]interface{}, len(args)) + for i, a := range args { + out[i] = applog.ScrubValue("", a) + } + return out } func (w *loggingDBWrapper) Exec(query string, args ...interface{}) (sql.Result, error) { - start := time.Now() - res, err := w.inner.Exec(query, args...) - logSQL(context.Background(), sqlLogEntry{Op: "exec", Query: query, Args: args, Err: err, Dur: time.Since(start)}) - return res, err + return w.ExecContext(context.Background(), query, args...) } func (w *loggingDBWrapper) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { start := time.Now() res, err := w.inner.ExecContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "exec", Query: query, Args: args, Err: err, Dur: time.Since(start)}) + logSQL(ctx, "exec", query, args, err, time.Since(start)) return res, err } func (w *loggingDBWrapper) Query(query string, args ...interface{}) (*sql.Rows, error) { - start := time.Now() - rows, err := w.inner.Query(query, args...) - logSQL(context.Background(), sqlLogEntry{Op: "query", Query: query, Args: args, Err: err, Dur: time.Since(start)}) - return rows, err + return w.QueryContext(context.Background(), query, args...) } func (w *loggingDBWrapper) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { start := time.Now() rows, err := w.inner.QueryContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "query", Query: query, Args: args, Err: err, Dur: time.Since(start)}) + logSQL(ctx, "query", query, args, err, time.Since(start)) return rows, err } func (w *loggingDBWrapper) QueryRow(query string, args ...interface{}) *sql.Row { - start := time.Now() - row := w.inner.QueryRow(query, args...) - logSQL(context.Background(), sqlLogEntry{Op: "query_row", Query: query, Args: args, Dur: time.Since(start)}) - return row + return w.QueryRowContext(context.Background(), query, args...) } func (w *loggingDBWrapper) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { start := time.Now() row := w.inner.QueryRowContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "query_row", Query: query, Args: args, Dur: time.Since(start)}) + logSQL(ctx, "query_row", query, args, nil, time.Since(start)) return row } @@ -101,7 +98,7 @@ func (w *loggingDBWrapper) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx if err != nil { return nil, err } - return &loggingTxWrapper{inner: tx, ctx: ctx}, nil + return &loggingTxWrapper{inner: tx}, nil } func (w *loggingDBWrapper) Close() error { return w.inner.Close() } @@ -109,55 +106,38 @@ func (w *loggingDBWrapper) Ping() error { return w.inner.Ping() } type loggingTxWrapper struct { inner TxWrapper - ctx context.Context -} - -func (w *loggingTxWrapper) txCtx() context.Context { - if w.ctx != nil { - return w.ctx - } - return context.Background() } func (w *loggingTxWrapper) Exec(query string, args ...interface{}) (sql.Result, error) { - start := time.Now() - res, err := w.inner.Exec(query, args...) - logSQL(w.txCtx(), sqlLogEntry{Op: "tx_exec", Query: query, Args: args, Err: err, Dur: time.Since(start)}) - return res, err + return w.ExecContext(context.Background(), query, args...) } func (w *loggingTxWrapper) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { start := time.Now() res, err := w.inner.ExecContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "tx_exec", Query: query, Args: args, Err: err, Dur: time.Since(start)}) + logSQL(ctx, "tx_exec", query, args, err, time.Since(start)) return res, err } func (w *loggingTxWrapper) Query(query string, args ...interface{}) (*sql.Rows, error) { - start := time.Now() - rows, err := w.inner.Query(query, args...) - logSQL(w.txCtx(), sqlLogEntry{Op: "tx_query", Query: query, Args: args, Err: err, Dur: time.Since(start)}) - return rows, err + return w.QueryContext(context.Background(), query, args...) } func (w *loggingTxWrapper) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { start := time.Now() rows, err := w.inner.QueryContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "tx_query", Query: query, Args: args, Err: err, Dur: time.Since(start)}) + logSQL(ctx, "tx_query", query, args, err, time.Since(start)) return rows, err } func (w *loggingTxWrapper) QueryRow(query string, args ...interface{}) *sql.Row { - start := time.Now() - row := w.inner.QueryRow(query, args...) - logSQL(w.txCtx(), sqlLogEntry{Op: "tx_query_row", Query: query, Args: args, Dur: time.Since(start)}) - return row + return w.QueryRowContext(context.Background(), query, args...) } func (w *loggingTxWrapper) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { start := time.Now() row := w.inner.QueryRowContext(ctx, query, args...) - logSQL(ctx, sqlLogEntry{Op: "tx_query_row", Query: query, Args: args, Dur: time.Since(start)}) + logSQL(ctx, "tx_query_row", query, args, nil, time.Since(start)) return row } diff --git a/core/orm/db_wrapper.go b/core/orm/db_wrapper.go index cdfbebfc..0af55f1d 100644 --- a/core/orm/db_wrapper.go +++ b/core/orm/db_wrapper.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "strings" + + "sumeru/core/applog" ) func quoteIdent(s string) string { @@ -92,27 +94,47 @@ func (w *sqlDBWrapper) Ping() error { } // TableExists checks if a table exists in the current database. +// On query failure it logs and returns false only after distinguishing Scan errors +// via tableExistsErr; prefer IsInitialized for bootstrap gates. func (w *sqlDBWrapper) TableExists(tableName string) bool { + exists, err := w.tableExistsErr(tableName) + if err != nil { + applog.WarnMsg(context.Background(), "orm", "table_exists", "information_schema lookup failed", err, map[string]interface{}{"table": tableName}) + return false + } + return exists +} + +func (w *sqlDBWrapper) tableExistsErr(tableName string) (bool, error) { var exists bool query := `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 )` - _ = w.db.QueryRow(query, tableName).Scan(&exists) - return exists + err := w.db.QueryRow(query, tableName).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil } // IsInitialized checks if the core Sumeru tables are present. +// DB errors are not treated as "uninitialized" (avoids accidental setup re-entry). func IsInitialized() bool { if DB == nil { return false } - // Check if sys_module exists as a proxy for initialization. - if wrapper, ok := DB.(*sqlDBWrapper); ok { - return wrapper.TableExists("sys_module") + wrapper, ok := DB.(*sqlDBWrapper) + if !ok { + return false } - return false + exists, err := wrapper.tableExistsErr("sys_module") + if err != nil { + applog.WarnMsg(context.Background(), "orm", "is_initialized", "sys_module existence check failed; assuming initialized", err, nil) + return true + } + return exists } // sqlTxWrapper implements TxWrapper using a standard *sql.Tx. diff --git a/core/orm/field_read.go b/core/orm/field_read.go index 134bfba3..e28b15e3 100644 --- a/core/orm/field_read.go +++ b/core/orm/field_read.go @@ -3,6 +3,8 @@ package orm import ( "context" "fmt" + + "sumeru/core/applog" ) var sensitiveReadFields = map[string]map[string]bool{ @@ -56,18 +58,26 @@ func RedactSearchResults(ctx context.Context, uid int, model string, rows []map[ func enrichRecordForRead(ctx context.Context, uid int, modelName string, record map[string]interface{}) { RedactRecordForRead(ctx, uid, modelName, record) - _ = ApplyComputes(ctx, modelName, record) + if err := ApplyComputes(ctx, modelName, record); err != nil { + applog.WarnMsg(ctx, "orm", "enrich_read", "ApplyComputes failed", err, map[string]interface{}{"model": modelName}) + } if !skipRelatedEnrichment(ctx) { - _ = ApplyRelatedFields(ctx, modelName, record) + if err := ApplyRelatedFields(ctx, modelName, record); err != nil { + applog.WarnMsg(ctx, "orm", "enrich_read", "ApplyRelatedFields failed", err, map[string]interface{}{"model": modelName}) + } } } func enrichRecordsForRead(ctx context.Context, uid int, modelName string, records []map[string]interface{}) { for _, record := range records { RedactRecordForRead(ctx, uid, modelName, record) - _ = ApplyComputes(ctx, modelName, record) + if err := ApplyComputes(ctx, modelName, record); err != nil { + applog.WarnMsg(ctx, "orm", "enrich_read", "ApplyComputes failed", err, map[string]interface{}{"model": modelName}) + } } if !skipRelatedEnrichment(ctx) { - _ = ApplyRelatedFieldsBatch(ctx, modelName, records) + if err := ApplyRelatedFieldsBatch(ctx, modelName, records); err != nil { + applog.WarnMsg(ctx, "orm", "enrich_read", "ApplyRelatedFieldsBatch failed", err, map[string]interface{}{"model": modelName}) + } } } diff --git a/core/orm/orm_op_log.go b/core/orm/orm_op_log.go index adaefda5..b2acfb4d 100644 --- a/core/orm/orm_op_log.go +++ b/core/orm/orm_op_log.go @@ -30,7 +30,8 @@ func logORMOperation(ctx context.Context, start time.Time, operation, modelName if err != nil { ev.Message = humanORMMessage(operation, modelName, false) ev.Status = "failure" - applog.Error(ctx, ev) + ev.Code = ClassifyLogCode(err) + applog.ErrorCode(ctx, ev.Code, ev.Message, ev) return } ev.Message = humanORMMessage(operation, modelName, true) @@ -76,14 +77,3 @@ func humanORMMessage(operation, modelName string, success bool) string { return fmt.Sprintf("%s on %s failed", operation, modelName) } } - -// logORMOperationKV adapts legacy key-value call sites during migration. -func logORMOperationKV(ctx context.Context, start time.Time, operation, modelName string, err error, keysAndValues ...interface{}) { - ctxMap := map[string]interface{}{} - for i := 0; i+1 < len(keysAndValues); i += 2 { - if k, ok := keysAndValues[i].(string); ok { - ctxMap[k] = keysAndValues[i+1] - } - } - logORMOperation(ctx, start, operation, modelName, err, ctxMap) -} diff --git a/core/orm/outbox_drain.go b/core/orm/outbox_drain.go index 6a61deb5..93b15d56 100644 --- a/core/orm/outbox_drain.go +++ b/core/orm/outbox_drain.go @@ -7,6 +7,7 @@ import ( "time" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/event" "sumeru/core/queue" ) @@ -30,6 +31,7 @@ func DrainOutboxOnce(ctx context.Context) int { `SELECT id, name, COALESCE(payload_json,''), COALESCE(actor,0) FROM `+tbl+ ` WHERE published_at IS NULL ORDER BY id LIMIT 100`) if err != nil { + warnOutbox(bypass, "outbox drain query failed", err, nil) return 0 } defer rows.Close() @@ -41,21 +43,17 @@ func DrainOutboxOnce(ctx context.Context) int { var name, payloadJSON string var actor int if err := rows.Scan(&id, &name, &payloadJSON, &actor); err != nil { + warnOutbox(bypass, "outbox drain scan failed", err, nil) continue } payload := map[string]interface{}{} if payloadJSON != "" { - _ = json.Unmarshal([]byte(payloadJSON), &payload) + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + warnOutbox(bypass, "outbox payload unmarshal failed", err, map[string]interface{}{"outbox_id": id, "event": name}) + } } if errs := event.Publish(bypass, event.Event{Name: name, Actor: actor, Payload: payload}); len(errs) > 0 { - applog.Warn(bypass, applog.Event{ - Message: "outbox publish failed", - Component: "orm", - Operation: "outbox_drain", - Status: "failed", - Context: map[string]interface{}{"outbox_id": id, "event": name}, - Err: errs[0], - }) + warnOutbox(bypass, "outbox publish failed", errs[0], map[string]interface{}{"outbox_id": id, "event": name}) continue } queue.Publish(bypass, "outbox", map[string]interface{}{ @@ -63,6 +61,7 @@ func DrainOutboxOnce(ctx context.Context) int { }) if _, err := DB.ExecContext(bypass, `UPDATE `+tbl+` SET published_at = $1 WHERE id = $2`, now, id); err != nil { + warnOutbox(bypass, "outbox mark published failed", err, map[string]interface{}{"outbox_id": id, "event": name}) continue } published++ @@ -70,6 +69,20 @@ func DrainOutboxOnce(ctx context.Context) int { return published } +func warnOutbox(ctx context.Context, message string, err error, fields map[string]interface{}) { + status := "partial" + if message == "outbox publish failed" { + status = "failure" + } + applog.WarnCode(ctx, errcode.InternalError, message, applog.Event{ + Component: "orm", + Operation: "outbox_drain", + Status: status, + Context: fields, + Err: err, + }) +} + // StartOutboxDrain begins a background ticker that drains pending outbox rows. func StartOutboxDrain(parent context.Context, every time.Duration) { outboxMu.Lock() diff --git a/core/orm/record_rules.go b/core/orm/record_rules.go index 3f39bc06..c3b52923 100644 --- a/core/orm/record_rules.go +++ b/core/orm/record_rules.go @@ -181,11 +181,20 @@ func BuildWhereWithRecordRules(ctx context.Context, uid int, model, op string, b return "", nil, err } if DevFeatureEnabled("access") { - applog.L(ctx).Debug("dev_access_rules", - "model", model, "op", op, "uid", uid, - "global_rules", len(parts.globals), "group_rules", len(parts.groups), - "allow_all_groups", parts.allowAllGroups, - ) + applog.Debug(ctx, applog.Event{ + Message: "access rules resolved", + Component: "orm", + Operation: "dev_access_rules", + Status: "success", + Context: map[string]interface{}{ + "resource": model, + "op": op, + "uid": uid, + "global_rules": len(parts.globals), + "group_rules": len(parts.groups), + "allow_all_groups": parts.allowAllGroups, + }, + }) } var clauses [][][]interface{} if len(base) > 0 { diff --git a/core/orm/schema_fk.go b/core/orm/schema_fk.go index 73adcdd8..20b4aa4a 100644 --- a/core/orm/schema_fk.go +++ b/core/orm/schema_fk.go @@ -5,7 +5,8 @@ import ( "fmt" "strings" - applog "sumeru/core/applog" + "sumeru/core/applog" + "sumeru/core/errcode" ) // ensureForeignKeys adds missing FK constraints for many2one columns (best-effort on existing DBs). @@ -39,10 +40,29 @@ func ensureForeignKeys(ctx context.Context, tbl schemaTable) error { if strings.Contains(strings.ToLower(err.Error()), "already exists") { continue } - applog.L(ctx).Warn("schema_sync_fk_skip", "table", tbl.TableName, "field", field.Name, "error", err.Error()) + applog.WarnCode(ctx, errcode.InternalError, "schema foreign key skipped", applog.Event{ + Component: "orm", + Operation: "schema_sync_fk", + Status: "partial", + Context: map[string]interface{}{ + "table": tbl.TableName, + "field": field.Name, + }, + Err: err, + }) continue } - applog.L(ctx).Info("schema_sync_fk", "table", tbl.TableName, "field", field.Name, "target", targetTable) + applog.Info(ctx, applog.Event{ + Message: "schema foreign key added", + Component: "orm", + Operation: "schema_sync_fk", + Status: "success", + Context: map[string]interface{}{ + "table": tbl.TableName, + "field": field.Name, + "target": targetTable, + }, + }) } return nil } diff --git a/core/orm/schema_sync.go b/core/orm/schema_sync.go index 8843e178..99761f3c 100644 --- a/core/orm/schema_sync.go +++ b/core/orm/schema_sync.go @@ -99,7 +99,16 @@ func syncModelSchema(ctx context.Context, model Model) error { if _, err := DB.ExecContext(ctx, q); err != nil { return fmt.Errorf("%s: %w", q, err) } - applog.L(ctx).Info("schema_sync", "table", tableName, "field", field.Name) + applog.Info(ctx, applog.Event{ + Message: "schema column synced", + Component: "orm", + Operation: "schema_sync", + Status: "success", + Context: map[string]interface{}{ + "table": tableName, + "field": field.Name, + }, + }) } tbl := schemaTable{ModelName: modelName, TableName: tableName, QuotedTable: quotedTable, Model: model} if err := dropStaleColumnUniques(ctx, tbl); err != nil { @@ -170,7 +179,16 @@ func dropStaleColumnUniques(ctx context.Context, tbl schemaTable) error { if _, err := DB.ExecContext(ctx, q); err != nil { return fmt.Errorf("drop unique %s.%s: %w", tbl.TableName, con, err) } - applog.L(ctx).Info("schema_sync_drop_unique", "table", tbl.TableName, "constraint", con) + applog.Info(ctx, applog.Event{ + Message: "schema unique constraint dropped", + Component: "orm", + Operation: "schema_sync_drop_unique", + Status: "success", + Context: map[string]interface{}{ + "table": tbl.TableName, + "constraint": con, + }, + }) } } return nil @@ -331,7 +349,16 @@ func EnsureModelColumns(ctx context.Context, model Model, extra []FieldDefinitio if _, err := DB.ExecContext(ctx, q); err != nil { return fmt.Errorf("%s: %w", q, err) } - applog.L(ctx).Info("schema_sync_extra", "table", tableName, "field", field.Name) + applog.Info(ctx, applog.Event{ + Message: "schema extra column synced", + Component: "orm", + Operation: "schema_sync_extra", + Status: "success", + Context: map[string]interface{}{ + "table": tableName, + "field": field.Name, + }, + }) } return nil } diff --git a/core/orm/setup_seed.go b/core/orm/setup_seed.go index af2f315a..60a89cab 100644 --- a/core/orm/setup_seed.go +++ b/core/orm/setup_seed.go @@ -10,13 +10,19 @@ import ( "golang.org/x/crypto/bcrypt" ) -func seedXmlID(ctx context.Context, module, xmlName, model string, coreID int) { - _, _ = Upsert(ctx, RegistryModel("sys.model.data"), map[string]interface{}{ +func seedXmlID(ctx context.Context, module, xmlName, model string, coreID int) error { + _, err := Upsert(ctx, RegistryModel("sys.model.data"), map[string]interface{}{ "module": module, "name": xmlName, "model": model, "core_id": coreID, }, "name") + if err != nil { + applog.WarnMsg(ctx, "orm", "seed_xml", "sys.model.data upsert failed", err, map[string]interface{}{ + "module": module, "name": xmlName, "model": model, + }) + } + return err } func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, err error) { @@ -43,7 +49,9 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap sys.module.category Administration: %w", err) } - seedXmlID(ctx, "base", "module_category_administration", "sys.module.category", catAdminID) + if err := seedXmlID(ctx, "base", "module_category_administration", "sys.module.category", catAdminID); err != nil { + return 0, 0, err + } catUserTypesID, err := Upsert(ctx, catModel, map[string]interface{}{ "name": "User types", "sequence": 2, @@ -51,7 +59,9 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap sys.module.category User types: %w", err) } - seedXmlID(ctx, "base", "module_category_user_types", "sys.module.category", catUserTypesID) + if err := seedXmlID(ctx, "base", "module_category_user_types", "sys.module.category", catUserTypesID); err != nil { + return 0, 0, err + } adminGID, err = Upsert(ctx, groupModel, map[string]interface{}{ "name": "Administration / Settings", @@ -61,7 +71,9 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap core.group admin: %w", err) } - seedXmlID(ctx, "base", "group_system", "core.group", adminGID) + if err := seedXmlID(ctx, "base", "group_system", "core.group", adminGID); err != nil { + return 0, 0, err + } userGID, err = Upsert(ctx, groupModel, map[string]interface{}{ "name": "User types / Internal User", @@ -71,7 +83,9 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap core.group user: %w", err) } - seedXmlID(ctx, "base", "group_user", "core.group", userGID) + if err := seedXmlID(ctx, "base", "group_user", "core.group", userGID); err != nil { + return 0, 0, err + } portalGID, err := Upsert(ctx, groupModel, map[string]interface{}{ "name": "User types / Portal", @@ -81,7 +95,9 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap core.group portal: %w", err) } - seedXmlID(ctx, "base", "group_portal", "core.group", portalGID) + if err := seedXmlID(ctx, "base", "group_portal", "core.group", portalGID); err != nil { + return 0, 0, err + } publicGID, err := Upsert(ctx, groupModel, map[string]interface{}{ "name": "User types / Public", @@ -91,9 +107,13 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if err != nil { return 0, 0, fmt.Errorf("bootstrap core.group public: %w", err) } - seedXmlID(ctx, "base", "group_public", "core.group", publicGID) + if err := seedXmlID(ctx, "base", "group_public", "core.group", publicGID); err != nil { + return 0, 0, err + } - _, _ = DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupImplied)+` (group_id, implied_group_id) VALUES ($1, $2) ON CONFLICT (group_id, implied_group_id) DO NOTHING`, adminGID, userGID) + if _, err := DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupImplied)+` (group_id, implied_group_id) VALUES ($1, $2) ON CONFLICT (group_id, implied_group_id) DO NOTHING`, adminGID, userGID); err != nil { + return 0, 0, fmt.Errorf("bootstrap group imply: %w", err) + } return adminGID, userGID, nil } @@ -138,7 +158,9 @@ func ensureBootstrapSecurity(ctx context.Context, first *SetupAdminParams) error if err != nil { return fmt.Errorf("bootstrap company: %w", err) } - seedXmlID(ctx, "base", "main_company", "core.company", compID) + if err := seedXmlID(ctx, "base", "main_company", "core.company", compID); err != nil { + return err + } login := strings.ToLower(first.Email) adminUID, err := Upsert(ctx, userModel, map[string]interface{}{ @@ -164,7 +186,9 @@ func ensureBootstrapSecurity(ctx context.Context, first *SetupAdminParams) error return fmt.Errorf("set administrator password: %w", err) } - seedXmlID(ctx, "base", "user_admin", "core.user", adminUID) + if err := seedXmlID(ctx, "base", "user_admin", "core.user", adminUID); err != nil { + return err + } if _, err := DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupUserRel)+` (user_id, group_id) VALUES ($1, $2) ON CONFLICT (user_id, group_id) DO NOTHING`, adminUID, adminGID); err != nil { return err @@ -188,14 +212,16 @@ func ensurePlatformDefaults(ctx context.Context) { return } inst := Registry["sys.sequence"] - _, _ = Create(ctx, inst, map[string]interface{}{ + if _, err := Create(ctx, inst, map[string]interface{}{ "name": "API Key", "code": "core.user.apikey", "prefix": "KEY/", "padding": 4, "number_next": 1, "active": true, - }) + }); err != nil { + applog.WarnMsg(ctx, "orm", "bootstrap", "API key sequence create failed", err, nil) + } } func ensureBootstrapACLs(ctx context.Context, adminGID, userGID int) { diff --git a/core/orm/sys_outbox.go b/core/orm/sys_outbox.go index f8857f6f..c8a7b0ce 100644 --- a/core/orm/sys_outbox.go +++ b/core/orm/sys_outbox.go @@ -6,6 +6,7 @@ import ( "time" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/modelmeta" ) @@ -25,8 +26,7 @@ func outboxValues(name string, actor int, payload map[string]interface{}) map[st if b, err := json.Marshal(payload); err == nil { pj = string(b) } else { - applog.Warn(context.Background(), applog.Event{ - Message: "Outbox payload marshal failed", + applog.WarnCode(context.Background(), errcode.InternalError, "Outbox payload marshal failed", applog.Event{ Component: "orm", Operation: "outbox", Status: "partial", @@ -44,10 +44,10 @@ func outboxValues(name string, actor int, payload map[string]interface{}) map[st } // EnqueueOutboxTx inserts on tx when non-nil. -func EnqueueOutboxTx(ctx context.Context, tx TxWrapper, name string, actor int, payload map[string]interface{}) { +func EnqueueOutboxTx(ctx context.Context, tx TxWrapper, name string, actor int, payload map[string]interface{}) error { if name == "" { - return + return nil } vals := outboxValues(name, actor, payload) - _ = insertSideEffectRow(ctx, tx, "sys.outbox.event", vals) + return insertSideEffectRow(ctx, tx, "sys.outbox.event", vals) } diff --git a/core/orm/ui_view_lookup.go b/core/orm/ui_view_lookup.go index fa7039ee..edd9c65b 100644 --- a/core/orm/ui_view_lookup.go +++ b/core/orm/ui_view_lookup.go @@ -21,7 +21,9 @@ func uiViewLookupLogErr(err error) error { func FindUIDefaultView(ctx context.Context, modelName, viewType string) (result map[string]interface{}, err error) { start := time.Now() defer func() { - logORMOperationKV(ctx, start, "find_ui_view", "sys.view", uiViewLookupLogErr(err), "target_model", modelName, "view_type", viewType, "found", result != nil) + logORMOperation(ctx, start, "find_ui_view", "sys.view", uiViewLookupLogErr(err), map[string]interface{}{ + "target_model": modelName, "view_type": viewType, "found": result != nil, + }) }() if _, ok := Registry["sys.view"]; !ok { return nil, fmt.Errorf("model sys.view not registered") @@ -78,7 +80,9 @@ func findUIDefaultViewByType(ctx context.Context, uid int, modelName, vt string) func FindUIViewByName(ctx context.Context, modelName, viewType, viewName string) (result map[string]interface{}, err error) { start := time.Now() defer func() { - logORMOperationKV(ctx, start, "find_ui_view_by_name", "sys.view", uiViewLookupLogErr(err), "target_model", modelName, "view_type", viewType, "view_name", viewName, "found", result != nil) + logORMOperation(ctx, start, "find_ui_view_by_name", "sys.view", uiViewLookupLogErr(err), map[string]interface{}{ + "target_model": modelName, "view_type": viewType, "view_name": viewName, "found": result != nil, + }) }() viewName = strings.TrimSpace(viewName) if viewName == "" { diff --git a/core/report/bulk_import.go b/core/report/bulk_import.go index c8de73bd..caa51bfa 100644 --- a/core/report/bulk_import.go +++ b/core/report/bulk_import.go @@ -86,7 +86,9 @@ func ExecuteBulkImport(ctx context.Context, in ExecuteBulkImportInput) (ImportRe } result.Created++ } - _ = orm.UpdateRecordByID(ctx, BulkModelName, in.BatchID, map[string]interface{}{"state": "done"}) + if err := orm.UpdateRecordByID(ctx, BulkModelName, in.BatchID, map[string]interface{}{"state": "done"}); err != nil { + return result, fmt.Errorf("mark batch done: %w", err) + } return result, nil } diff --git a/core/report/bulk_staging.go b/core/report/bulk_staging.go index c62c9e10..00b27435 100644 --- a/core/report/bulk_staging.go +++ b/core/report/bulk_staging.go @@ -68,7 +68,9 @@ func CreateBatch(ctx context.Context, in CreateBatchInput) (batchID int, err err if err != nil { return 0, err } - _ = orm.UpdateRecordByID(ctx, "sys.attachment", attID, map[string]interface{}{"res_id": batchID}) + if err := orm.UpdateRecordByID(ctx, "sys.attachment", attID, map[string]interface{}{"res_id": batchID}); err != nil { + return batchID, fmt.Errorf("link attachment to batch: %w", err) + } return batchID, nil } diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index e3124d58..a09be919 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -8,6 +8,7 @@ import ( "time" "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/event" "sumeru/core/orm" ) @@ -104,19 +105,44 @@ func runDue(ctx context.Context) { executeCron(bypass, CronRunInput{ID: row.id, Name: row.name, EventName: row.eventName, Code: row.code}) interval := cronIntervalTx(bypass, tx, row.id) next := now.Add(interval) - _, _ = tx.ExecContext(bypass, + if _, err := tx.ExecContext(bypass, `UPDATE `+tbl+` SET next_call = $1, last_call = $2 WHERE id = $3`, next, now, row.id, - ) + ); err != nil { + applog.WarnCode(bypass, errcode.CronUpdateFailed, "cron next_call update failed", applog.Event{ + Component: "scheduler", + Operation: "run_due", + Status: "failure", + Context: map[string]interface{}{"cron_id": row.id}, + Err: err, + }) + return + } + } + if err := tx.Commit(); err != nil { + applog.WarnCode(bypass, errcode.CronCommitFailed, "cron transaction commit failed", applog.Event{ + Component: "scheduler", + Operation: "run_due", + Status: "failure", + Err: err, + }) } - _ = tx.Commit() } func cronIntervalTx(ctx context.Context, tx orm.TxWrapper, id int64) time.Duration { var mins sql.NullInt64 - _ = tx.QueryRowContext(ctx, + if err := tx.QueryRowContext(ctx, `SELECT interval_number FROM `+orm.MustQuotedTableName("sys.cron")+` WHERE id = $1`, id, - ).Scan(&mins) + ).Scan(&mins); err != nil { + applog.WarnCode(ctx, errcode.InternalError, "cron interval read failed; using default 60m", applog.Event{ + Component: "scheduler", + Operation: "cron_interval", + Status: "partial", + Context: map[string]interface{}{"cron_id": id}, + Err: err, + }) + return time.Hour + } n := int(mins.Int64) if n <= 0 { n = 60 @@ -132,7 +158,17 @@ type CronRunInput struct { } func executeCron(ctx context.Context, in CronRunInput) { - applog.L(ctx).Info("scheduler.cron", "id", in.ID, "name", in.Name, "code", in.Code) + applog.Debug(ctx, applog.Event{ + Message: "cron job starting", + Component: "scheduler", + Operation: "cron_run", + Status: "success", + Context: map[string]interface{}{ + "cron_id": in.ID, + "cron_name": in.Name, + "cron_code": in.Code, + }, + }) payload := map[string]interface{}{"cron_id": in.ID, "cron_name": in.Name, "code": in.Code} _ = event.Publish(ctx, event.Event{Name: "cron.tick", Payload: payload}) if eventName := strings.TrimSpace(in.EventName); eventName != "" { @@ -140,12 +176,11 @@ func executeCron(ctx context.Context, in CronRunInput) { } if fn := lookupCronHandler(in.Code); fn != nil { if err := fn(ctx, payload); err != nil { - applog.Warn(ctx, applog.Event{ - Message: "cron handler failed", + applog.WarnCode(ctx, errcode.CronHandlerFailed, "cron handler failed", applog.Event{ Component: "scheduler", Operation: "cron_handler", - Status: "failed", - Context: map[string]interface{}{"cron_id": in.ID, "code": in.Code}, + Status: "failure", + Context: map[string]interface{}{"cron_id": in.ID, "cron_code": in.Code}, Err: err, }) } diff --git a/core/sdk/compute.go b/core/sdk/compute.go index d1726dd6..dbb94e32 100644 --- a/core/sdk/compute.go +++ b/core/sdk/compute.go @@ -1,14 +1,13 @@ package sdk -import "context" - -// ComputeContext is passed to compute handlers registered via orm.RegisterCompute. +// ComputeContext carries the record id for compute helpers that need it. +// Request-scoped context is passed separately to compute handlers (orm.ComputeFunc); +// this struct intentionally does not store context.Context. type ComputeContext struct { - Ctx context.Context - ID int + ID int } // NewComputeContext builds a compute context for a record. -func NewComputeContext(ctx context.Context, id int) ComputeContext { - return ComputeContext{Ctx: ctx, ID: id} +func NewComputeContext(id int) ComputeContext { + return ComputeContext{ID: id} } diff --git a/core/server/api/errors.go b/core/server/api/errors.go index b3e954ac..c29bd422 100644 --- a/core/server/api/errors.go +++ b/core/server/api/errors.go @@ -10,15 +10,15 @@ const ( CodeInvalidJSON = "INVALID_JSON" CodeInvalidArgs = "INVALID_ARGS" CodeInvalidBody = "INVALID_BODY" - CodeValidationError = "VALIDATION_ERROR" + CodeValidationError = "VALIDATION_ERROR" // errcode.ValidationError CodeModelNotFound = "MODEL_NOT_FOUND" - CodeNotFound = "NOT_FOUND" + CodeNotFound = "NOT_FOUND" // errcode.NotFound CodeMethodNotAllowed = "METHOD_NOT_ALLOWED" - CodeUnauthorized = "UNAUTHORIZED" - CodeAccessDenied = "ACCESS_DENIED" + CodeUnauthorized = "UNAUTHORIZED" // errcode.Unauthorized + CodeAccessDenied = "ACCESS_DENIED" // errcode.AccessDenied CodePayloadTooLarge = "PAYLOAD_TOO_LARGE" CodeUnsupportedMediaType = "UNSUPPORTED_MEDIA_TYPE" - CodeInternalError = "INTERNAL_ERROR" + CodeInternalError = "INTERNAL_ERROR" // errcode.InternalError ) type codedError struct { diff --git a/core/server/web/apikey_create.go b/core/server/web/apikey_create.go index bb3357fb..104b1b62 100644 --- a/core/server/web/apikey_create.go +++ b/core/server/web/apikey_create.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "sumeru/core/errcode" "sumeru/core/orm" ) @@ -29,7 +30,17 @@ func ActionCreateAPIKey(w http.ResponseWriter, r *http.Request) { rawKey, err := orm.CreateAPIKeyForUser(ctx, targetUserID, keyName) if err != nil { - WebLogf(ctx, "/web/action/create_api_key", "create API key for user %d: %v", targetUserID, err) + WebLogEvent(ctx, WebLogInput{ + Route: "/web/action/create_api_key", + Message: "Could not create API key", + Code: errcode.InternalError, + Operation: "create_api_key", + Status: logStatusFailure, + Err: err, + ContextFields: map[string]interface{}{ + "user_id": targetUserID, + }, + }) http.Error(w, "Could not create API key", http.StatusInternalServerError) return } diff --git a/core/server/web/auth.go b/core/server/web/auth.go index f2ee4789..32a906f6 100644 --- a/core/server/web/auth.go +++ b/core/server/web/auth.go @@ -16,9 +16,9 @@ import ( "sumeru/core/applog" "sumeru/core/engine/assets" "sumeru/core/engine/render" + "sumeru/core/errcode" "sumeru/core/mail" "sumeru/core/orm" - "sumeru/core/sdk/platformmsg" "sumeru/core/server/config" "golang.org/x/crypto/bcrypt" @@ -173,11 +173,27 @@ func LoginPost(w http.ResponseWriter, r *http.Request) { user, authenticated := verifyLoginCredentials(r.Context(), credentials, clientIP) if !authenticated { + applog.WarnCode(r.Context(), errcode.InvalidCredentials, "Invalid login or password", applog.Event{ + Component: "web", + Operation: "login", + Status: "failure", + Context: map[string]interface{}{ + "route": loginRoute, + "ip": clientIP, + }, + }) writeLoginPage(w, r, http.StatusUnauthorized, credentials.Next, invalidLoginMessage) return } if err := CreateSession(w, user.ID); err != nil { - WebLogf(r.Context(), loginRoute, "session: %v", err) + WebLogEvent(r.Context(), WebLogInput{ + Route: loginRoute, + Message: "Could not start session", + Code: errcode.InternalError, + Operation: "session_create", + Status: logStatusFailure, + Err: err, + }) http.Error(w, "Could not start session", http.StatusInternalServerError) return } @@ -211,7 +227,17 @@ func ActionResetPassword(w http.ResponseWriter, r *http.Request) { loginURL := loginRoute if mail.Configured() && to != "" { if err := mail.SendPasswordResetEmail(r.Context(), to, loginName, loginURL); err != nil { - WebLogf(r.Context(), resetPasswordRoute, "login-link email failed for user id=%s: %v", userID, err) + WebLogEvent(r.Context(), WebLogInput{ + Route: resetPasswordRoute, + Message: "login-link email failed", + Code: errcode.InternalError, + Operation: "login_link_email", + Status: logStatusFailure, + Err: err, + ContextFields: map[string]interface{}{ + "user_id": userID, + }, + }) } else { WebLogf(r.Context(), resetPasswordRoute, "login-link email sent for user id=%s login=%q", userID, loginName) } @@ -306,7 +332,8 @@ func logHTTPRequestEnd(ctx context.Context, r *http.Request, statusCode int, dur if statusCode >= 500 { event.Message = "HTTP request failed" event.Status = "failure" - applog.Error(ctx, event) + event.Code = errcode.InternalError + applog.ErrorCode(ctx, event.Code, event.Message, event) return } event.Message = "HTTP request completed" @@ -335,7 +362,14 @@ func writeLoginPage(w http.ResponseWriter, r *http.Request, statusCode int, next tmpl, err := getLoginTemplate() if err != nil { if statusCode == http.StatusOK { - WebLogf(r.Context(), loginRoute, "%s: login template: %v", platformmsg.MsgHTTPTemplateError, err) + WebLogEvent(r.Context(), WebLogInput{ + Route: loginRoute, + Message: "login template unavailable", + Code: errcode.InternalError, + Operation: "login_template", + Status: logStatusFailure, + Err: err, + }) http.Error(w, "Login page unavailable", http.StatusInternalServerError) return } diff --git a/core/server/web/auth_helpers.go b/core/server/web/auth_helpers.go index c46c0fc0..9b32b956 100644 --- a/core/server/web/auth_helpers.go +++ b/core/server/web/auth_helpers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" + "sumeru/core/errcode" "sumeru/core/orm" ) @@ -15,7 +16,7 @@ func writeJSON(w http.ResponseWriter, ctx context.Context, route string, v inter if err := enc.Encode(v); err != nil && ctx != nil && route != "" { WebLogEvent(ctx, WebLogInput{ Route: route, Message: "Failed to encode JSON response", - Operation: "write", Status: "partial", Err: err, + Code: errcode.InternalError, Operation: "write", Status: "partial", Err: err, }) } } @@ -56,7 +57,7 @@ func requireModelAccess(w http.ResponseWriter, r *http.Request, model, perm stri if err := orm.CheckModelAccess(r.Context(), orm.SecurityUID(r.Context()), model, perm); err != nil { WebLogEvent(r.Context(), WebLogInput{ Route: r.URL.Path, Message: "Model access denied", - Operation: "access", Status: "failure", Err: err, + Code: errcode.AccessDenied, Operation: "access", Status: "failure", Err: err, ContextFields: map[string]interface{}{"resource": model, "permission": perm}, }) http.Error(w, forbiddenMessage, http.StatusForbidden) diff --git a/core/server/web/auth_session.go b/core/server/web/auth_session.go index c9257a89..aca54d3d 100644 --- a/core/server/web/auth_session.go +++ b/core/server/web/auth_session.go @@ -7,6 +7,8 @@ import ( "net/http" "time" + "sumeru/core/applog" + "sumeru/core/errcode" "sumeru/core/orm" "sumeru/core/server/config" ) @@ -82,11 +84,18 @@ func SessionUserID(r *http.Request) int { return 0 } // Sliding idle expiry (DB-backed; works across instances). - _, _ = orm.DB.Exec( + if _, err := orm.DB.Exec( `UPDATE `+sessionTable+` SET expires_at = $1 WHERE sid = $2 AND expires_at > NOW()`, time.Now().UTC().Add(sessionSlidingTTL), cookie.Value, - ) + ); err != nil { + applog.WarnCode(r.Context(), errcode.InternalError, "sliding session expiry update failed", applog.Event{ + Component: "web", + Operation: "session_slide", + Status: "partial", + Err: err, + }) + } return userID } @@ -95,7 +104,14 @@ func DestroySession(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookieName) if err == nil && cookie.Value != "" { sessionTable := orm.MustQuotedTableName("sys.session") - _, _ = orm.DB.Exec(`DELETE FROM `+sessionTable+` WHERE sid = $1`, cookie.Value) + if _, err := orm.DB.Exec(`DELETE FROM `+sessionTable+` WHERE sid = $1`, cookie.Value); err != nil { + applog.WarnCode(r.Context(), errcode.InternalError, "session delete failed", applog.Event{ + Component: "web", + Operation: "session_destroy", + Status: "partial", + Err: err, + }) + } } ClearSessionCookie(w) } diff --git a/core/server/web/bulk_handlers.go b/core/server/web/bulk_handlers.go index 0fcae7fe..70af8504 100644 --- a/core/server/web/bulk_handlers.go +++ b/core/server/web/bulk_handlers.go @@ -108,7 +108,14 @@ func BulkCancelHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "id required", http.StatusBadRequest) return } - _ = report.CancelBatch(r.Context(), batchID) - batch, _ := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) - http.Redirect(w, r, SafeWebNext(orm.AsString(batch["next_url"]), homeRoute), http.StatusSeeOther) + if err := report.CancelBatch(r.Context(), batchID); err != nil { + http.Error(w, "cancel failed: "+err.Error(), http.StatusInternalServerError) + return + } + batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) + next := homeRoute + if err == nil { + next = SafeWebNext(orm.AsString(batch["next_url"]), homeRoute) + } + http.Redirect(w, r, next, http.StatusSeeOther) } diff --git a/core/server/web/company_switch.go b/core/server/web/company_switch.go index 960440e8..f306a1c5 100644 --- a/core/server/web/company_switch.go +++ b/core/server/web/company_switch.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "sumeru/core/errcode" "sumeru/core/orm" ) @@ -47,7 +48,18 @@ func switchActiveCompany(ctx context.Context, userID, companyID int) { return } if err := updateUserActiveCompany(ctx, userID, companyID); err != nil { - WebLogf(ctx, companySwitchRoute, "update company_id: %v", err) + WebLogEvent(ctx, WebLogInput{ + Route: companySwitchRoute, + Message: "Could not update active company", + Code: errcode.InternalError, + Operation: "company_switch", + Status: logStatusFailure, + Err: err, + ContextFields: map[string]interface{}{ + "company_id": companyID, + "user_id": userID, + }, + }) return } WebLogNavigation(ctx, companySwitchRoute, "company_switch", "Active company switched", map[string]interface{}{ diff --git a/core/server/web/record_error_flash.go b/core/server/web/record_error_flash.go index 7d438392..0760c56d 100644 --- a/core/server/web/record_error_flash.go +++ b/core/server/web/record_error_flash.go @@ -7,7 +7,6 @@ import ( "net/url" "strings" - "sumeru/core/applog" "sumeru/core/orm" ) @@ -131,13 +130,10 @@ func redirectRecordError(w http.ResponseWriter, r *http.Request, nextURL, operat title, body, details, fieldErrors := userFacingRecordError(operation, model, err) WebLogEvent(ctx, WebLogInput{ Route: operationRoute(operation), Message: body, + Code: orm.ClassifyLogCode(err), Operation: operation, Status: logStatusFailure, Err: err, ContextFields: map[string]interface{}{"model": model}, }) - applog.DebugMsg(ctx, webLogComponent, operation, "record POST failed", map[string]interface{}{ - "model": model, - "error": err.Error(), - }) SetRecordErrorFlash(w, PageFlash{ Kind: "error", Title: title, diff --git a/core/server/web/rpc_json.go b/core/server/web/rpc_json.go index fe3cc909..26b229a4 100644 --- a/core/server/web/rpc_json.go +++ b/core/server/web/rpc_json.go @@ -118,11 +118,14 @@ func logRPCDispatch(ctx context.Context, requestBody []byte, response api.RPCRes }, } if !response.OK && response.Error != nil { - event.Message = "RPC call failed" + event.Message = response.Error.Message + if event.Message == "" { + event.Message = "RPC call failed" + } event.Status = "failure" - event.Context["error_code"] = response.Error.Code + event.Code = response.Error.Code event.Context["error"] = response.Error.Message - applog.Error(ctx, event) + applog.ErrorCode(ctx, event.Code, event.Message, event) return } event.Message = "RPC call completed" diff --git a/core/server/web/setup_handlers.go b/core/server/web/setup_handlers.go index ef533358..bf8017f2 100644 --- a/core/server/web/setup_handlers.go +++ b/core/server/web/setup_handlers.go @@ -14,6 +14,7 @@ import ( "sumeru/core/applog" "sumeru/core/engine/assets" + "sumeru/core/errcode" "sumeru/core/module" "sumeru/core/orm" "sumeru/core/server/config" @@ -115,14 +116,17 @@ func runFirstTimeSetup(ctx context.Context, adminParams orm.SetupAdminParams) er return nil } -func logSetupFailure(ctx context.Context, message string, err error) { - applog.Error(ctx, applog.Event{ - Message: message, +func logSetupFailure(ctx context.Context, message string, err error, fields ...map[string]interface{}) { + ev := applog.Event{ Component: "web", Operation: setupOperation, Status: "failure", Err: err, - }) + } + if len(fields) > 0 { + ev.Context = fields[0] + } + applog.ErrorCode(ctx, errcode.InternalError, message, ev) } func scheduleSetupRestart() { @@ -147,19 +151,13 @@ func writeSetupPage(w http.ResponseWriter, ctx context.Context, pageData setupPa templatePath := filepath.Join(config.AppConfig.TemplatesPath, setupTemplateFile) templateFile, err := template.ParseFiles(templatePath) if err != nil { - applog.Error(ctx, applog.Event{ - Message: "Failed to parse setup template", Component: "web", Operation: setupOperation, - Status: "failure", Err: err, Context: map[string]interface{}{"template": templatePath}, - }) + logSetupFailure(ctx, "Failed to parse setup template", err, map[string]interface{}{"template": templatePath}) http.Error(w, "Setup template missing", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := templateFile.Execute(w, pageData); err != nil { - applog.Error(ctx, applog.Event{ - Message: "Failed to execute setup template", Component: "web", Operation: setupOperation, - Status: "failure", Err: err, - }) + logSetupFailure(ctx, "Failed to execute setup template", err) } } diff --git a/core/server/web/shell_render.go b/core/server/web/shell_render.go index 810e67c8..891e13f8 100644 --- a/core/server/web/shell_render.go +++ b/core/server/web/shell_render.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sumeru/core/engine/render" + "sumeru/core/errcode" "sumeru/core/server/config" ) @@ -34,10 +35,7 @@ func renderShellPage(w http.ResponseWriter, r *http.Request, opts shellPageOpts) page := finalizeShellPage(ctx, r, opts, innerHTML, route) layoutHTML, err := render.RenderPage(ctx, config.AppConfig.TemplatesPath, page) if err != nil { - WebLogEvent(ctx, WebLogInput{ - Route: route, Message: "Failed to render page layout", - Operation: "render", Status: "failure", Err: err, - }) + webLogFail(ctx, route, "render", "Failed to render page layout", err, logStatusFailure, nil) http.Error(w, "Layout render error", http.StatusInternalServerError) return } @@ -58,21 +56,14 @@ func executeInnerTemplate(ctx context.Context, w http.ResponseWriter, route stri } templateFile, err := template.ParseFiles(templatePaths...) if err != nil { - WebLogEvent(ctx, WebLogInput{ - Route: route, Message: "Failed to parse inner template", - Operation: "render", Status: "failure", Err: err, - ContextFields: map[string]interface{}{"template": opts.InnerTemplate}, - }) + webLogFail(ctx, route, "render", "Failed to parse inner template", err, logStatusFailure, map[string]interface{}{"template": opts.InnerTemplate}) http.Error(w, "Template error", http.StatusInternalServerError) return "", false } var innerBuffer bytes.Buffer if err := templateFile.Execute(&innerBuffer, opts.InnerData); err != nil { - WebLogEvent(ctx, WebLogInput{ - Route: route, Message: "Failed to execute inner template", - Operation: "render", Status: "failure", Err: err, - }) + webLogFail(ctx, route, "render", "Failed to execute inner template", err, logStatusFailure, nil) http.Error(w, "Template error", http.StatusInternalServerError) return "", false } @@ -146,9 +137,18 @@ func resolveExtraScripts(pageScripts, optScripts []string) []string { func writeHTML(w http.ResponseWriter, ctx context.Context, route, html string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write([]byte(html)); err != nil { - WebLogEvent(ctx, WebLogInput{ - Route: route, Message: "Failed to write HTML response", - Operation: "write", Status: "partial", Err: err, - }) + webLogFail(ctx, route, "write", "Failed to write HTML response", err, logStatusPartial, nil) } } + +func webLogFail(ctx context.Context, route, operation, message string, err error, status string, fields map[string]interface{}) { + WebLogEvent(ctx, WebLogInput{ + Route: route, + Message: message, + Code: errcode.InternalError, + Operation: operation, + Status: status, + Err: err, + ContextFields: fields, + }) +} diff --git a/core/server/web/web_request_helpers.go b/core/server/web/web_request_helpers.go index abd93358..9718093c 100644 --- a/core/server/web/web_request_helpers.go +++ b/core/server/web/web_request_helpers.go @@ -12,17 +12,16 @@ import ( "sumeru/core/orm" ) -// WebLogInput holds structured web log event fields. type WebLogInput struct { Route string Message string + Code string Operation string Status string Err error ContextFields map[string]interface{} } -// WebLogEvent logs a structured web event using the applog contract. func WebLogEvent(ctx context.Context, in WebLogInput) { contextFields := in.ContextFields if contextFields == nil { @@ -32,23 +31,28 @@ func WebLogEvent(ctx context.Context, in WebLogInput) { event := applog.Event{ Message: in.Message, + Code: in.Code, Component: webLogComponent, Operation: in.Operation, Status: in.Status, Context: contextFields, Err: in.Err, } - emitWebLogEvent(ctx, event) -} - -func emitWebLogEvent(ctx context.Context, event applog.Event) { switch { case event.Err != nil || event.Status == logStatusFailure: if event.Status == "" { event.Status = logStatusFailure } + if event.Code != "" { + applog.ErrorCode(ctx, event.Code, event.Message, event) + return + } applog.Error(ctx, event) case event.Status == logStatusPartial: + if event.Code != "" { + applog.WarnCode(ctx, event.Code, event.Message, event) + return + } applog.Warn(ctx, event) default: applog.Info(ctx, event) @@ -65,7 +69,7 @@ func WebLogf(ctx context.Context, route, format string, args ...interface{}) { }) } -// WebLogNavigation emits an INFO-level audit event for successful navigation (menu, view, module, company). +// WebLogNavigation emits INFO for successful UI navigation. func WebLogNavigation(ctx context.Context, route, operation, message string, fields map[string]interface{}) { WebLogEvent(ctx, WebLogInput{ Route: route, Message: message, Operation: operation, Status: logStatusSuccess, ContextFields: fields, diff --git a/core/server/web/workspace.go b/core/server/web/workspace.go index 31713149..1095613e 100644 --- a/core/server/web/workspace.go +++ b/core/server/web/workspace.go @@ -7,6 +7,7 @@ import ( "strings" "sumeru/core/engine/render" + "sumeru/core/errcode" "sumeru/core/orm" "sumeru/core/server/config" ) @@ -85,7 +86,16 @@ func redirectIfMenuAccessDenied(w http.ResponseWriter, r *http.Request, menuQuer return false } - WebLogf(r.Context(), workspaceRoute, "menu_id=%s denied by access_groups", menuID) + WebLogEvent(r.Context(), WebLogInput{ + Route: workspaceRoute, + Message: "menu access denied", + Code: errcode.AccessDenied, + Operation: "menu_access", + Status: logStatusFailure, + ContextFields: map[string]interface{}{ + "menu_id": menuID, + }, + }) http.Redirect(w, r, homeRoute, http.StatusFound) return true } @@ -112,24 +122,44 @@ func respondActionNotFound(w http.ResponseWriter, actionID int) { } func respondWorkspaceLoadError(w http.ResponseWriter, ctx context.Context, err error) { - WebLogf(ctx, workspaceRoute, "load view data: %v", err) - http.Error(w, err.Error(), httpStatusFromWorkspaceError(err)) + code, status := classifyWorkspaceLoadError(err) + WebLogEvent(ctx, WebLogInput{ + Route: workspaceRoute, + Message: "load view data failed", + Code: code, + Operation: "load_view", + Status: logStatusFailure, + Err: err, + }) + http.Error(w, err.Error(), status) } -func httpStatusFromWorkspaceError(err error) int { - message := err.Error() +func classifyWorkspaceLoadError(err error) (code string, status int) { + code = orm.ClassifyLogCode(err) + msg := err.Error() switch { - case strings.Contains(message, workspaceErrInvalidID): - return http.StatusBadRequest - case strings.Contains(message, workspaceErrNoView), strings.Contains(message, workspaceErrNotFound): - return http.StatusNotFound - case strings.Contains(message, "access denied"): - return http.StatusForbidden + case strings.Contains(msg, workspaceErrInvalidID): + return errcode.ValidationError, http.StatusBadRequest + case strings.Contains(msg, workspaceErrNoView), strings.Contains(msg, workspaceErrNotFound): + return errcode.NotFound, http.StatusNotFound + } + switch code { + case errcode.AccessDenied: + return code, http.StatusForbidden + case errcode.RecordNotFound, errcode.NotFound: + return code, http.StatusNotFound + case errcode.ValidationError: + return code, http.StatusBadRequest default: - return http.StatusInternalServerError + return code, http.StatusInternalServerError } } +func httpStatusFromWorkspaceError(err error) int { + _, status := classifyWorkspaceLoadError(err) + return status +} + func logWorkspaceViewOpened(ctx context.Context, route string, req workspaceRequest, actionID int, resolved *resolvedWorkspaceView) { recordID, _ := parsePositiveRecordID(req.recordID) WebLogNavigation(ctx, route, workspaceViewOpenOp, "Workspace view opened", map[string]interface{}{ diff --git a/core/swc/src/model/modifiers.ts b/core/swc/src/model/modifiers.ts index ad2bcaf2..9979b351 100644 --- a/core/swc/src/model/modifiers.ts +++ b/core/swc/src/model/modifiers.ts @@ -56,10 +56,12 @@ export function fieldDomain(field: SwcArchField, record?: SwcRecord): unknown[] const raw = field.options?.domain; if (!raw) return undefined; try { - const parsed = JSON.parse(raw) as unknown[]; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return undefined; if (!record) return parsed; return evalDomainPlaceholders(parsed, record); - } catch { + } catch (err) { + console.warn("fieldDomain: invalid domain JSON", field.name, err); return undefined; } } diff --git a/core/swc/src/services/bus.ts b/core/swc/src/services/bus.ts index 06ecd083..4329e2b0 100644 --- a/core/swc/src/services/bus.ts +++ b/core/swc/src/services/bus.ts @@ -2,6 +2,13 @@ import { SWC_API_BASE } from "../constants/routes.js"; type BusHandler = (payload: unknown) => void; +function parseBusMessage(raw: unknown): { channel: string; payload: unknown } | null { + if (typeof raw !== "object" || raw === null) return null; + const channel = (raw as { channel?: unknown }).channel; + if (typeof channel !== "string" || channel === "") return null; + return { channel, payload: (raw as { payload?: unknown }).payload }; +} + /** Client event bus with optional WebSocket live updates from /web/swc/bus. */ export class BusService { private readonly handlers = new Map>(); @@ -29,17 +36,18 @@ export class BusService { this.ws = new WebSocket(`${proto}//${window.location.host}${url}`); this.ws.addEventListener("message", (ev) => { try { - const msg = JSON.parse(String(ev.data)) as { channel: string; payload: unknown }; - if (msg.channel) this.emit(msg.channel, msg.payload); - } catch { - /* ignore malformed */ + const parsed: unknown = JSON.parse(String(ev.data)); + const msg = parseBusMessage(parsed); + if (msg) this.emit(msg.channel, msg.payload); + } catch (err) { + console.warn("swc bus: malformed message", err); } }); this.ws.addEventListener("close", () => { this.ws = null; }); - } catch { - /* WebSocket unavailable — local-only bus */ + } catch (err) { + console.warn("swc bus: WebSocket unavailable; local-only bus", err); } } diff --git a/core/swc/src/services/rpc.ts b/core/swc/src/services/rpc.ts index 7cbd2ff6..be3bffae 100644 --- a/core/swc/src/services/rpc.ts +++ b/core/swc/src/services/rpc.ts @@ -6,6 +6,10 @@ interface RpcEnvelope { error?: { code?: string; message: string; details?: unknown }; } +function isRpcEnvelope(data: unknown): data is RpcEnvelope { + return typeof data === "object" && data !== null; +} + export class RpcService { private readonly url: string; private readonly csrfToken: string; @@ -47,7 +51,11 @@ export class RpcService { if (!res.ok) { throw new SwcError(`RPC HTTP ${res.status}`, "rpc_http"); } - const data = (await res.json()) as RpcEnvelope; + const raw: unknown = await res.json(); + if (!isRpcEnvelope(raw)) { + throw new SwcError("RPC response is not an object", "rpc_error"); + } + const data = raw as RpcEnvelope; if (data.ok === false || data.error) { throw new SwcError(data.error?.message ?? "RPC failed", "rpc_error", data.error); } diff --git a/core/swc/src/shell/pinned-apps.ts b/core/swc/src/shell/pinned-apps.ts index b5cba999..7c874186 100644 --- a/core/swc/src/shell/pinned-apps.ts +++ b/core/swc/src/shell/pinned-apps.ts @@ -90,13 +90,13 @@ export function initPinnedApps(http: HttpService, initial: string[]): void { setPinnedCache(saved); try { localStorage.removeItem(KEY_PINNED_LEGACY); - } catch { - /* ignore */ + } catch (err) { + console.warn("pinned-apps: legacy key cleanup failed", err); } applyTopNavFilter(); }) - .catch(() => { - /* ignore migration failure */ + .catch((err) => { + console.warn("pinned-apps: migration failed", err); }); } diff --git a/core/swc/src/util/shell-storage.ts b/core/swc/src/util/shell-storage.ts index 4e0968bd..8c3e6d46 100644 --- a/core/swc/src/util/shell-storage.ts +++ b/core/swc/src/util/shell-storage.ts @@ -4,10 +4,15 @@ export const KEY_SIDEBAR = "sum.shell.sidebarCollapsed"; export const KEY_ACTIVITY_WIDTH = "sum.shell.activityWidthPx"; export const KEY_ACTIVITY_HIDDEN = "sum.shell.activityHidden"; +function storageWarn(op: string, key: string, err: unknown): void { + console.warn(`shell-storage ${op} failed`, key, err); +} + export function readBool(key: string): boolean { try { return localStorage.getItem(key) === "1"; - } catch { + } catch (err) { + storageWarn("readBool", key, err); return false; } } @@ -15,8 +20,8 @@ export function readBool(key: string): boolean { export function writeBool(key: string, value: boolean): void { try { localStorage.setItem(key, value ? "1" : "0"); - } catch { - /* quota or private mode */ + } catch (err) { + storageWarn("writeBool", key, err); } } @@ -24,8 +29,8 @@ export function readActivityWidth(): number { try { const n = parseInt(localStorage.getItem(KEY_ACTIVITY_WIDTH) ?? "", 10); if (n >= 200 && n <= 520) return n; - } catch { - /* ignore */ + } catch (err) { + storageWarn("readActivityWidth", KEY_ACTIVITY_WIDTH, err); } return 300; } @@ -33,8 +38,8 @@ export function readActivityWidth(): number { export function writeActivityWidth(px: number): void { try { localStorage.setItem(KEY_ACTIVITY_WIDTH, String(Math.round(px))); - } catch { - /* ignore */ + } catch (err) { + storageWarn("writeActivityWidth", KEY_ACTIVITY_WIDTH, err); } } @@ -42,9 +47,16 @@ export function readJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); if (!raw) return fallback; - const value = JSON.parse(raw) as unknown; - return (Array.isArray(value) ? value : fallback) as T; - } catch { + const value: unknown = JSON.parse(raw); + if (Array.isArray(fallback)) { + return (Array.isArray(value) ? value : fallback) as T; + } + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return value as T; + } + return fallback; + } catch (err) { + storageWarn("readJSON", key, err); return fallback; } } @@ -52,7 +64,7 @@ export function readJSON(key: string, fallback: T): T { export function writeJSON(key: string, value: unknown): void { try { localStorage.setItem(key, JSON.stringify(value)); - } catch { - /* ignore */ + } catch (err) { + storageWarn("writeJSON", key, err); } } diff --git a/core/swc/src/views/map/map-leaflet.ts b/core/swc/src/views/map/map-leaflet.ts index 39df354c..4ee28bf4 100644 --- a/core/swc/src/views/map/map-leaflet.ts +++ b/core/swc/src/views/map/map-leaflet.ts @@ -5,17 +5,46 @@ export interface MapMarker { label: string; } +/** Minimal Leaflet surface used by mountLeafletMap (loaded from CDN). */ +interface LeafletMarker { + bindPopup(html: string): LeafletMarker; + on(event: string, fn: () => void): void; +} + +interface LeafletMap { + setView(latlng: [number, number], zoom: number): void; + fitBounds(bounds: unknown, opts?: { padding?: [number, number] }): void; + remove(): void; +} + +interface LeafletNS { + map(el: HTMLElement, opts?: { scrollWheelZoom?: boolean }): LeafletMap; + tileLayer( + url: string, + opts?: { attribution?: string; maxZoom?: number }, + ): { addTo(map: LeafletMap): void }; + marker(latlng: [number, number]): LeafletMarker & { addTo(map: LeafletMap): LeafletMarker }; + latLngBounds(points: [number, number][]): unknown; +} + +type WindowWithLeaflet = Window & { L?: LeafletNS }; + const LEAFLET_CSS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; const LEAFLET_JS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; -let leafletPromise: Promise | null = null; +let leafletPromise: Promise | null = null; + +function leafletFromWindow(): LeafletNS | undefined { + return (window as WindowWithLeaflet).L; +} -function loadLeaflet(): Promise { +function loadLeaflet(): Promise { if (typeof window === "undefined") { return Promise.reject(new Error("no window")); } - if ((window as any).L) { - return Promise.resolve((window as any).L); + const existing = leafletFromWindow(); + if (existing) { + return Promise.resolve(existing); } if (!leafletPromise) { leafletPromise = new Promise((resolve, reject) => { @@ -28,12 +57,19 @@ function loadLeaflet(): Promise { const script = document.createElement("script"); script.src = LEAFLET_JS; script.async = true; - script.onload = () => resolve((window as any).L); + script.onload = () => { + const L = leafletFromWindow(); + if (!L) { + reject(new Error("leaflet missing after load")); + return; + } + resolve(L); + }; script.onerror = () => reject(new Error("leaflet load failed")); document.head.appendChild(script); }); } - return leafletPromise.then(() => (window as any).L); + return leafletPromise; } export async function mountLeafletMap( @@ -48,12 +84,10 @@ export async function mountLeafletMap( maxZoom: 19, }).addTo(map); - const layer: any[] = []; for (const marker of markers) { const m = L.marker([marker.lat, marker.lng]).addTo(map); m.bindPopup(marker.label); m.on("click", () => onSelect(marker.id)); - layer.push(m); } if (markers.length === 1) { diff --git a/test/core/applog/scrub_code_test.go b/test/core/applog/scrub_code_test.go new file mode 100644 index 00000000..401e0ba4 --- /dev/null +++ b/test/core/applog/scrub_code_test.go @@ -0,0 +1,116 @@ +package applog_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "sumeru/core/applog" + "sumeru/core/server/config" +) + +func TestScrubMap_redactsSecrets(t *testing.T) { + got := applog.ScrubMap(map[string]interface{}{ + "user_id": 1, + "password": "secret123", + "api_token": "tok", + "Authorization": "Bearer x", + "field": "email", + "nested": map[string]interface{}{ + "key_hash": "abc", + "ok": true, + }, + }) + if got["password"] != applog.RedactedPlaceholder { + t.Fatalf("password=%v", got["password"]) + } + if got["api_token"] != applog.RedactedPlaceholder { + t.Fatalf("api_token=%v", got["api_token"]) + } + if got["Authorization"] != applog.RedactedPlaceholder { + t.Fatalf("Authorization=%v", got["Authorization"]) + } + if got["user_id"] != 1 { + t.Fatalf("user_id=%v", got["user_id"]) + } + nested, ok := got["nested"].(map[string]interface{}) + if !ok { + t.Fatalf("nested=%T", got["nested"]) + } + if nested["key_hash"] != applog.RedactedPlaceholder || nested["ok"] != true { + t.Fatalf("nested=%v", nested) + } +} + +func TestIsSecretKey_exactSidDoesNotMatchInside(t *testing.T) { + if !applog.IsSecretKey("sid") { + t.Fatal("sid should be secret") + } + if applog.IsSecretKey("inside") { + t.Fatal("inside must not match sid exact rule") + } + if !applog.TextContainsSecretKeyword(`UPDATE sys_session SET sid = $1`) { + t.Fatal("SQL with sid column should look sensitive") + } +} + +func TestErrorCode_emitsTopLevelErrorCode(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "test.log") + if err := applog.SetupFromConfig(&config.Config{ + LogEnabled: true, + LogStdout: false, + LogFile: logPath, + LogRolling: false, + DevMode: true, + LogTimezone: "UTC", + }); err != nil { + t.Fatal(err) + } + + applog.ErrorCode(context.Background(), "EMAIL_ALREADY_EXISTS", "Email is already registered", applog.Event{ + Component: "web", + Operation: "user_create", + Context: map[string]interface{}{ + "user_id": 1, + "field": "email", + "password": "should-not-appear", + }, + }) + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + line := lines[len(lines)-1] + var m map[string]interface{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("invalid json: %v\n%s", err, line) + } + if m["error_code"] != "EMAIL_ALREADY_EXISTS" { + t.Fatalf("error_code=%v", m["error_code"]) + } + if m["message"] != "Email is already registered" { + t.Fatalf("message=%v", m["message"]) + } + if m["level"] != "ERROR" { + t.Fatalf("level=%v", m["level"]) + } + ctxObj, ok := m["context"].(map[string]interface{}) + if !ok { + t.Fatalf("context=%T", m["context"]) + } + if ctxObj["error_code"] != "EMAIL_ALREADY_EXISTS" { + t.Fatalf("context.error_code=%v", ctxObj["error_code"]) + } + if ctxObj["password"] != applog.RedactedPlaceholder { + t.Fatalf("password not scrubbed: %v", ctxObj["password"]) + } + if strings.Contains(line, "should-not-appear") { + t.Fatal("secret value leaked into log line") + } +} diff --git a/test/core/sdk/compute_context_test.go b/test/core/sdk/compute_context_test.go new file mode 100644 index 00000000..8e1471a0 --- /dev/null +++ b/test/core/sdk/compute_context_test.go @@ -0,0 +1,14 @@ +package sdk_test + +import ( + "testing" + + "sumeru/core/sdk" +) + +func TestNewComputeContext(t *testing.T) { + c := sdk.NewComputeContext(42) + if c.ID != 42 { + t.Fatalf("ID=%d", c.ID) + } +}