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
4 changes: 4 additions & 0 deletions .changes/unreleased/Fixed-113.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: Fixed
body: Config-time warnings (deprecated `dashboard` key, unknown `DEVCLOUD_SERVICES` tier) now honor `logging.format`/`logging.level` instead of always printing as plain text before the logger is configured
custom:
Issue: "113"
43 changes: 43 additions & 0 deletions cmd/devcloud/buffer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"context"
"log/slog"
)

// bufferHandler is a slog.Handler that captures every record instead of
// emitting it, so log calls made before the operator-configured handler is
// installed (notably config.parse() warnings) can be replayed afterwards and
// honor logging.format / logging.level. See flushTo.
//
// ponytail: single-threaded startup, add a mutex if buffered logging ever
// goes concurrent.
type bufferHandler struct {
records []slog.Record
}

// Enabled buffers all levels; the real handler filters on replay in flushTo.
func (h *bufferHandler) Enabled(context.Context, slog.Level) bool { return true }

func (h *bufferHandler) Handle(_ context.Context, r slog.Record) error {
h.records = append(h.records, r.Clone())
return nil
}

// WithAttrs/WithGroup return the handler unchanged: config-time logging uses
// plain slog.Warn/Info without attr or group chaining, so nothing is lost.
func (h *bufferHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *bufferHandler) WithGroup(string) slog.Handler { return h }

// flushTo replays buffered records through dst, honoring dst's level filter,
// then clears the buffer.
func (h *bufferHandler) flushTo(dst slog.Handler) {
for _, r := range h.records {
if dst.Enabled(context.Background(), r.Level) {
_ = dst.Handle(context.Background(), r)
}
}
h.records = nil
}
49 changes: 49 additions & 0 deletions cmd/devcloud/buffer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
)

// TestBufferHandler_FlushHonorsFormatAndLevel verifies that records buffered
// before the real handler is installed are replayed as JSON and filtered by
// the destination handler's level — the two acceptance criteria from #113.
func TestBufferHandler_FlushHonorsFormatAndLevel(t *testing.T) {
buf := &bufferHandler{}
log := slog.New(buf)
log.Info("config file not found")
log.Warn("dashboard key deprecated", "token", "tierX")

var out bytes.Buffer
dst := slog.NewJSONHandler(&out, &slog.HandlerOptions{Level: slog.LevelWarn})
buf.flushTo(dst)

got := out.String()
if strings.Contains(got, "config file not found") {
t.Errorf("info record should be dropped at level=warn, got: %s", got)
}
if !strings.Contains(got, `"dashboard key deprecated"`) || !strings.Contains(got, `"token":"tierX"`) {
t.Errorf("warn record (with attrs) should be replayed as JSON, got: %s", got)
}

// flush clears the buffer so a second flush is a no-op.
out.Reset()
buf.flushTo(dst)
if out.Len() != 0 {
t.Errorf("second flush should emit nothing, got: %s", out.String())
}
}

// TestBufferHandler_EnabledBuffersAllLevels confirms buffering does not drop
// records below the eventual threshold; the level filter applies only on flush.
func TestBufferHandler_EnabledBuffersAllLevels(t *testing.T) {
buf := &bufferHandler{}
if !buf.Enabled(context.Background(), slog.LevelDebug) {
t.Fatal("bufferHandler must buffer all levels, including debug")
}
}
11 changes: 11 additions & 0 deletions cmd/devcloud/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ func main() {
cfgPath := flag.String("config", "", "Path to config file (optional; uses ./devcloud.yaml if present, else embedded defaults)")
flag.Parse()

// Buffer log records emitted while loading config (parse() deprecation and
// unknown-tier warnings, etc.) so they can be replayed through the
// operator-configured handler below and honor logging.format / level.
buf := &bufferHandler{}
slog.SetDefault(slog.New(buf))

var (
cfg *config.Config
err error
Expand All @@ -44,11 +50,16 @@ func main() {
cfg, err = config.LoadOrDefault("devcloud.yaml")
}
if err != nil {
// Load failed, so there is no logging config to honor; fall back to the
// default handler, flush any buffered warnings, then surface the error.
setupLogging(config.LoggingConfig{})
buf.flushTo(slog.Default().Handler())
slog.Error("failed to load config", "error", err)
os.Exit(1)
}

setupLogging(cfg.Logging)
buf.flushTo(slog.Default().Handler())

registry := plugin.DefaultRegistry

Expand Down