refactor(daemon): log user notices at severity-mapped level
d.notify logged every notice at Warn regardless of severity, so a routine NoticeInfo (e.g. a successful clipboard copy) would emit a WARN line, drowning out real problems at the default VEV_LOG=info verbosity. Add slogLevelFor to map NoticeInfo/Warn/Error to the matching slog level and log through d.log.Log(d.serveCtx, ...) using the daemon's existing ambient context.
- commit
- 52bea82d9e827b176a59c26c6c56cf134a8dc442
- author
- brice · · 2026-07-20 12:39 UTC
- parent
- 04dc983
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +59 -1
M internal/usecase/daemon/notify.go +16 -1
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "log/slog" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -113,6 +114,20 @@ func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |||
| 113 | 114 | } | |
| 114 | 115 | } | |
| 115 | 116 | ||
| 117 | + | // slogLevelFor maps a notice severity to the slog level it should log at, so | |
| 118 | + | // routine info notices (e.g. a successful clipboard copy) don't drown out | |
| 119 | + | // real warnings at the default VEV_LOG=info verbosity. | |
| 120 | + | func slogLevelFor(sev domain.NoticeSeverity) slog.Level { | |
| 121 | + | switch sev { | |
| 122 | + | case domain.NoticeInfo: | |
| 123 | + | return slog.LevelInfo | |
| 124 | + | case domain.NoticeWarn: | |
| 125 | + | return slog.LevelWarn | |
| 126 | + | default: | |
| 127 | + | return slog.LevelError | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 116 | 131 | // noticeDetails renders the full Unwrap chain for the yank/history views. The | |
| 117 | 132 | // toast itself never shows this — only the UserError's own message. | |
| 118 | 133 | func noticeDetails(err error) string { | |
| @@ -167,7 +182,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 167 | 182 | n.SessionID = sess.id | |
| 168 | 183 | } | |
| 169 | 184 | n = d.notices.record(n) | |
| 170 | - | d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 185 | + | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 171 | 186 | ||
| 172 | 187 | if sess != nil { | |
| 173 | 188 | sess.mu.Lock() | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "log/slog" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -113,6 +114,20 @@ func noticeTTL(sev domain.NoticeSeverity) time.Duration { | |||||
| 113 | } | 114 | } | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
| 117 | + | // slogLevelFor maps a notice severity to the slog level it should log at, so | |||
| 118 | + | // routine info notices (e.g. a successful clipboard copy) don't drown out | |||
| 119 | + | // real warnings at the default VEV_LOG=info verbosity. | |||
| 120 | + | func slogLevelFor(sev domain.NoticeSeverity) slog.Level { | |||
| 121 | + | switch sev { | |||
| 122 | + | case domain.NoticeInfo: | |||
| 123 | + | return slog.LevelInfo | |||
| 124 | + | case domain.NoticeWarn: | |||
| 125 | + | return slog.LevelWarn | |||
| 126 | + | default: | |||
| 127 | + | return slog.LevelError | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 116 | // noticeDetails renders the full Unwrap chain for the yank/history views. The | 131 | // noticeDetails renders the full Unwrap chain for the yank/history views. The | ||
| 117 | // toast itself never shows this — only the UserError's own message. | 132 | // toast itself never shows this — only the UserError's own message. | ||
| 118 | func noticeDetails(err error) string { | 133 | func noticeDetails(err error) string { | ||
| @@ -167,7 +182,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||||
| 167 | n.SessionID = sess.id | 182 | n.SessionID = sess.id | ||
| 168 | } | 183 | } | ||
| 169 | n = d.notices.record(n) | 184 | n = d.notices.record(n) | ||
| 170 | - | d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | 185 | + | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) |
| 171 | 186 | ||||
| 172 | if sess != nil { | 187 | if sess != nil { | ||
| 173 | sess.mu.Lock() | 188 | sess.mu.Lock() | ||
M internal/usecase/daemon/notify_test.go +43 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "log/slog" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "testing" | |
| @@ -442,6 +443,48 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 442 | 443 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | |
| 443 | 444 | } | |
| 444 | 445 | ||
| 446 | + | // levelCaptureHandler records the slog.Level of every record it's handed, so | |
| 447 | + | // tests can assert on log verbosity without parsing formatted output. | |
| 448 | + | type levelCaptureHandler struct { | |
| 449 | + | mu sync.Mutex | |
| 450 | + | levels []slog.Level | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true } | |
| 454 | + | ||
| 455 | + | func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error { | |
| 456 | + | h.mu.Lock() | |
| 457 | + | defer h.mu.Unlock() | |
| 458 | + | h.levels = append(h.levels, r.Level) | |
| 459 | + | return nil | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } | |
| 463 | + | func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h } | |
| 464 | + | ||
| 465 | + | func TestNotifyLogsAtSeverityLevel(t *testing.T) { | |
| 466 | + | tests := []struct { | |
| 467 | + | name string | |
| 468 | + | sev domain.NoticeSeverity | |
| 469 | + | want slog.Level | |
| 470 | + | }{ | |
| 471 | + | {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo}, | |
| 472 | + | {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn}, | |
| 473 | + | {"error severity logs at error", domain.NoticeError, slog.LevelError}, | |
| 474 | + | } | |
| 475 | + | for _, tt := range tests { | |
| 476 | + | t.Run(tt.name, func(t *testing.T) { | |
| 477 | + | d := newTestDaemon(t, nil, newNoticeClock()) | |
| 478 | + | capture := &levelCaptureHandler{} | |
| 479 | + | d.log = slog.New(capture) | |
| 480 | + | ||
| 481 | + | d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil) | |
| 482 | + | ||
| 483 | + | require.Equal(t, []slog.Level{tt.want}, capture.levels) | |
| 484 | + | }) | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | ||
| 445 | 488 | func TestShowToastCoalesceAndTrim(t *testing.T) { | |
| 446 | 489 | t.Run("same code and scope coalesces", func(t *testing.T) { | |
| 447 | 490 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "log/slog" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -442,6 +443,48 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||||
| 442 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | 443 | require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue") | ||
| 443 | } | 444 | } | ||
| 444 | 445 | ||||
| 446 | + | // levelCaptureHandler records the slog.Level of every record it's handed, so | |||
| 447 | + | // tests can assert on log verbosity without parsing formatted output. | |||
| 448 | + | type levelCaptureHandler struct { | |||
| 449 | + | mu sync.Mutex | |||
| 450 | + | levels []slog.Level | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true } | |||
| 454 | + | ||||
| 455 | + | func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error { | |||
| 456 | + | h.mu.Lock() | |||
| 457 | + | defer h.mu.Unlock() | |||
| 458 | + | h.levels = append(h.levels, r.Level) | |||
| 459 | + | return nil | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } | |||
| 463 | + | func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h } | |||
| 464 | + | ||||
| 465 | + | func TestNotifyLogsAtSeverityLevel(t *testing.T) { | |||
| 466 | + | tests := []struct { | |||
| 467 | + | name string | |||
| 468 | + | sev domain.NoticeSeverity | |||
| 469 | + | want slog.Level | |||
| 470 | + | }{ | |||
| 471 | + | {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo}, | |||
| 472 | + | {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn}, | |||
| 473 | + | {"error severity logs at error", domain.NoticeError, slog.LevelError}, | |||
| 474 | + | } | |||
| 475 | + | for _, tt := range tests { | |||
| 476 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 477 | + | d := newTestDaemon(t, nil, newNoticeClock()) | |||
| 478 | + | capture := &levelCaptureHandler{} | |||
| 479 | + | d.log = slog.New(capture) | |||
| 480 | + | ||||
| 481 | + | d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil) | |||
| 482 | + | ||||
| 483 | + | require.Equal(t, []slog.Level{tt.want}, capture.levels) | |||
| 484 | + | }) | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 487 | + | ||||
| 445 | func TestShowToastCoalesceAndTrim(t *testing.T) { | 488 | func TestShowToastCoalesceAndTrim(t *testing.T) { | ||
| 446 | t.Run("same code and scope coalesces", func(t *testing.T) { | 489 | t.Run("same code and scope coalesces", func(t *testing.T) { | ||
| 447 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 490 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||