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)
+ }
+}