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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
```

Expand All @@ -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/...`.

Expand Down
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
49 changes: 35 additions & 14 deletions cmd/sumeru-shell/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

"sumeru/core/applog"
"sumeru/core/orm"
"sumeru/core/server/cliboot"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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() {
Expand Down Expand Up @@ -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))
}
11 changes: 10 additions & 1 deletion core/applog/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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))
}
Expand Down
16 changes: 11 additions & 5 deletions core/applog/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"sync"
"time"

"sumeru/core/errcode"
)

var (
Expand Down Expand Up @@ -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)
}

Expand Down
71 changes: 71 additions & 0 deletions core/applog/scrub.go
Original file line number Diff line number Diff line change
@@ -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",
}
47 changes: 28 additions & 19 deletions core/applog/stdio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading