From 5311545a9efdfa7d3a5e99914813bd3ab2e2ed7b Mon Sep 17 00:00:00 2001
From: Colin Adler <colin1adler@gmail.com>
Date: Mon, 31 Jul 2023 14:12:38 -0500
Subject: [PATCH 01/10] chore: use `golang.org/x/term` (#183)

---
 go.mod                       | 3 +--
 go.sum                       | 2 --
 internal/entryhuman/entry.go | 4 ++--
 3 files changed, 3 insertions(+), 6 deletions(-)

diff --git a/go.mod b/go.mod
index 11fe29f..ca6640f 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	go.opentelemetry.io/otel/sdk v1.16.0
 	go.opentelemetry.io/otel/trace v1.16.0
 	go.uber.org/goleak v1.2.1
-	golang.org/x/crypto v0.11.0
+	golang.org/x/term v0.10.0
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2
 	google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e
 )
@@ -32,7 +32,6 @@ require (
 	go.opentelemetry.io/otel/metric v1.16.0 // indirect
 	golang.org/x/net v0.12.0 // indirect
 	golang.org/x/sys v0.10.0 // indirect
-	golang.org/x/term v0.10.0 // indirect
 	golang.org/x/text v0.11.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect
diff --git a/go.sum b/go.sum
index a23ef10..aefa004 100644
--- a/go.sum
+++ b/go.sum
@@ -49,8 +49,6 @@ go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZE
 go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
 go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
 go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
-golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
-golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
 golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
 golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/entryhuman/entry.go b/internal/entryhuman/entry.go
index a6d30f9..cd63ccb 100644
--- a/internal/entryhuman/entry.go
+++ b/internal/entryhuman/entry.go
@@ -17,7 +17,7 @@ import (
 
 	"github.com/charmbracelet/lipgloss"
 	"github.com/muesli/termenv"
-	"golang.org/x/crypto/ssh/terminal"
+	"golang.org/x/term"
 	"golang.org/x/xerrors"
 
 	"cdr.dev/slog"
@@ -227,7 +227,7 @@ func isTTY(w io.Writer) bool {
 	f, ok := w.(interface {
 		Fd() uintptr
 	})
-	return ok && terminal.IsTerminal(int(f.Fd()))
+	return ok && term.IsTerminal(int(f.Fd()))
 }
 
 func shouldColor(w io.Writer) bool {

From 9f58760f5b6b9e37993613e25f6778e4850727db Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 17 Aug 2023 15:06:57 -0500
Subject: [PATCH 02/10] chore: bump golang.org/x/term from 0.10.0 to 0.11.0
 (#184)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 go.mod | 4 ++--
 go.sum | 8 ++++----
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/go.mod b/go.mod
index ca6640f..1435215 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	go.opentelemetry.io/otel/sdk v1.16.0
 	go.opentelemetry.io/otel/trace v1.16.0
 	go.uber.org/goleak v1.2.1
-	golang.org/x/term v0.10.0
+	golang.org/x/term v0.11.0
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2
 	google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e
 )
@@ -31,7 +31,7 @@ require (
 	go.opentelemetry.io/otel v1.16.0 // indirect
 	go.opentelemetry.io/otel/metric v1.16.0 // indirect
 	golang.org/x/net v0.12.0 // indirect
-	golang.org/x/sys v0.10.0 // indirect
+	golang.org/x/sys v0.11.0 // indirect
 	golang.org/x/text v0.11.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect
diff --git a/go.sum b/go.sum
index aefa004..750857b 100644
--- a/go.sum
+++ b/go.sum
@@ -52,10 +52,10 @@ go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
 golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
 golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
-golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
-golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
+golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
+golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
 golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
 golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

From 0830a57ace3f1d1bb73ef823630307bfe993a710 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 17 Aug 2023 15:19:27 -0500
Subject: [PATCH 03/10] chore: bump cloud.google.com/go/logging from 1.7.0 to
 1.8.1 (#186)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 go.mod | 2 +-
 go.sum | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/go.mod b/go.mod
index 1435215..52efddb 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.20
 
 require (
 	cloud.google.com/go/compute/metadata v0.2.3
-	cloud.google.com/go/logging v1.7.0
+	cloud.google.com/go/logging v1.8.1
 	github.com/charmbracelet/lipgloss v0.7.1
 	github.com/google/go-cmp v0.5.9
 	github.com/muesli/termenv v0.15.2
diff --git a/go.sum b/go.sum
index 750857b..9eb2fc3 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopT
 cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I=
-cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
+cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU=
+cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI=
 cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
 cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=

From b386d5d10a809d5d5b9d4f45d92236ae980f5979 Mon Sep 17 00:00:00 2001
From: Colin Adler <colin1adler@gmail.com>
Date: Thu, 17 Aug 2023 15:42:40 -0500
Subject: [PATCH 04/10] =?UTF-8?q?Revert=20"Revert=20"fix:=20Use=20SyscallC?=
 =?UTF-8?q?onn=20for=20isTTY=20which=20is=20safe=20during=20f=E2=80=A6=20(?=
 =?UTF-8?q?#187)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 internal/entryhuman/entry.go      | 23 ++++++++++++++++++++---
 internal/entryhuman/entry_test.go | 28 ++++++++++++++++++++++++++++
 2 files changed, 48 insertions(+), 3 deletions(-)

diff --git a/internal/entryhuman/entry.go b/internal/entryhuman/entry.go
index cd63ccb..8af4d82 100644
--- a/internal/entryhuman/entry.go
+++ b/internal/entryhuman/entry.go
@@ -12,6 +12,7 @@ import (
 	"reflect"
 	"strconv"
 	"strings"
+	"syscall"
 	"time"
 	"unicode"
 
@@ -224,9 +225,25 @@ func isTTY(w io.Writer) bool {
 	if w == forceColorWriter {
 		return true
 	}
-	f, ok := w.(interface {
-		Fd() uintptr
-	})
+	// SyscallConn is safe during file close.
+	if sc, ok := w.(interface {
+		SyscallConn() (syscall.RawConn, error)
+	}); ok {
+		conn, err := sc.SyscallConn()
+		if err != nil {
+			return false
+		}
+		var isTerm bool
+		err = conn.Control(func(fd uintptr) {
+			isTerm = term.IsTerminal(int(fd))
+		})
+		if err != nil {
+			return false
+		}
+		return isTerm
+	}
+	// Fallback to unsafe Fd.
+	f, ok := w.(interface{ Fd() uintptr })
 	return ok && term.IsTerminal(int(f.Fd()))
 }
 
diff --git a/internal/entryhuman/entry_test.go b/internal/entryhuman/entry_test.go
index 06ba312..45a885a 100644
--- a/internal/entryhuman/entry_test.go
+++ b/internal/entryhuman/entry_test.go
@@ -178,6 +178,34 @@ func TestEntry(t *testing.T) {
 			assert.Equal(t, "entry matches", string(wantByt), gotBuf.String())
 		})
 	}
+
+	t.Run("isTTY during file close", func(t *testing.T) {
+		t.Parallel()
+
+		tmpdir := t.TempDir()
+		f, err := os.CreateTemp(tmpdir, "slog")
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer f.Close()
+
+		done := make(chan struct{}, 2)
+		go func() {
+			entryhuman.Fmt(new(bytes.Buffer), f, slog.SinkEntry{
+				Level: slog.LevelCritical,
+				Fields: slog.M(
+					slog.F("hey", "hi"),
+				),
+			})
+			done <- struct{}{}
+		}()
+		go func() {
+			_ = f.Close()
+			done <- struct{}{}
+		}()
+		<-done
+		<-done
+	})
 }
 
 func BenchmarkFmt(b *testing.B) {

From 3e17d6de9749b621453144652c8ff5706b00381d Mon Sep 17 00:00:00 2001
From: Spike Curtis <spike@spikecurtis.com>
Date: Fri, 1 Sep 2023 08:30:36 +0400
Subject: [PATCH 05/10] feat: add more prominent failure notice on slogtest
 error (#190)

* feat: add more prominent failure notice on slogtest error

Signed-off-by: Spike Curtis <spike@coder.com>

* reinstated Fatal logs calling tb.Fatal

Signed-off-by: Spike Curtis <spike@coder.com>

---------

Signed-off-by: Spike Curtis <spike@coder.com>
---
 sloggers/slogtest/t.go      | 16 ++++++++++------
 sloggers/slogtest/t_test.go |  2 +-
 2 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/sloggers/slogtest/t.go b/sloggers/slogtest/t.go
index 9d81766..6923054 100644
--- a/sloggers/slogtest/t.go
+++ b/sloggers/slogtest/t.go
@@ -7,6 +7,7 @@ package slogtest // import "cdr.dev/slog/sloggers/slogtest"
 
 import (
 	"context"
+	"fmt"
 	"log"
 	"os"
 	"strings"
@@ -78,19 +79,22 @@ func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
 	// The testing package logs to stdout and not stderr.
 	entryhuman.Fmt(&sb, os.Stdout, ent)
 
-	s := sb.String()
-
 	switch ent.Level {
 	case slog.LevelDebug, slog.LevelInfo, slog.LevelWarn:
-		ts.tb.Log(s)
+		ts.tb.Log(sb.String())
 	case slog.LevelError, slog.LevelCritical:
 		if ts.opts.IgnoreErrors {
-			ts.tb.Log(s)
+			ts.tb.Log(sb.String())
 		} else {
-			ts.tb.Error(s)
+			sb.WriteString(fmt.Sprintf(
+				"\n *** slogtest: log detected at level %s; TEST FAILURE ***",
+				ent.Level,
+			))
+			ts.tb.Error(sb.String())
 		}
 	case slog.LevelFatal:
-		ts.tb.Fatal(s)
+		sb.WriteString("\n *** slogtest: FATAL log detected; TEST FAILURE ***")
+		ts.tb.Fatal(sb.String())
 	}
 }
 
diff --git a/sloggers/slogtest/t_test.go b/sloggers/slogtest/t_test.go
index d55fa88..9c1b88a 100644
--- a/sloggers/slogtest/t_test.go
+++ b/sloggers/slogtest/t_test.go
@@ -55,7 +55,7 @@ func TestCleanup(t *testing.T) {
 		fn()
 	}
 
-	// This shoud not log since the logger was cleaned up.
+	// This should not log since the logger was cleaned up.
 	l.Info(bg, "hello")
 	assert.Equal(t, "no logs", 0, tb.logs)
 }

From f0c466fabe10641afdbcc629938df29f941b3d18 Mon Sep 17 00:00:00 2001
From: Mathias Fredriksson <mafredri@gmail.com>
Date: Fri, 29 Sep 2023 22:36:52 +0300
Subject: [PATCH 06/10] fix: add additional severity field to stackdriver
 (#194)

---
 sloggers/slogstackdriver/slogstackdriver.go      | 1 +
 sloggers/slogstackdriver/slogstackdriver_test.go | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/sloggers/slogstackdriver/slogstackdriver.go b/sloggers/slogstackdriver/slogstackdriver.go
index f9eed55..9772ed4 100644
--- a/sloggers/slogstackdriver/slogstackdriver.go
+++ b/sloggers/slogstackdriver/slogstackdriver.go
@@ -56,6 +56,7 @@ func (s stackdriverSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
 	// https://cloud.google.com/stackdriver/docs/solutions/agents/ops-agent/configuration#special-fields
 	e := slog.M(
 		slog.F("logging.googleapis.com/severity", sev(ent.Level)),
+		slog.F("severity", sev(ent.Level)),
 		slog.F("message", ent.Message),
 		// Unfortunately, both of these fields are required.
 		slog.F("timestampSeconds", ent.Time.Unix()),
diff --git a/sloggers/slogstackdriver/slogstackdriver_test.go b/sloggers/slogstackdriver/slogstackdriver_test.go
index ec58ba7..7a79985 100644
--- a/sloggers/slogstackdriver/slogstackdriver_test.go
+++ b/sloggers/slogstackdriver/slogstackdriver_test.go
@@ -43,7 +43,7 @@ func TestStackdriver(t *testing.T) {
 
 	j := entryjson.Filter(b.String(), "timestampSeconds")
 	j = entryjson.Filter(j, "timestampNanos")
-	exp := fmt.Sprintf(`{"logging.googleapis.com/severity":"ERROR","message":"line1\n\nline2","logging.googleapis.com/sourceLocation":{"file":"%v","line":40,"function":"cdr.dev/slog/sloggers/slogstackdriver_test.TestStackdriver"},"logging.googleapis.com/operation":{"producer":"meow"},"logging.googleapis.com/trace":"projects/%v/traces/%v","logging.googleapis.com/spanId":"%v","logging.googleapis.com/trace_sampled":%v,"wowow":"me\nyou"}
+	exp := fmt.Sprintf(`{"logging.googleapis.com/severity":"ERROR","severity":"ERROR","message":"line1\n\nline2","logging.googleapis.com/sourceLocation":{"file":"%v","line":40,"function":"cdr.dev/slog/sloggers/slogstackdriver_test.TestStackdriver"},"logging.googleapis.com/operation":{"producer":"meow"},"logging.googleapis.com/trace":"projects/%v/traces/%v","logging.googleapis.com/spanId":"%v","logging.googleapis.com/trace_sampled":%v,"wowow":"me\nyou"}
 `, slogstackdriverTestFile, projectID, span.SpanContext().TraceID(), span.SpanContext().SpanID(), span.SpanContext().IsSampled())
 	assert.Equal(t, "entry", exp, j)
 }

From 20367d4aede690b72a0c43f847b7fc0645f94a92 Mon Sep 17 00:00:00 2001
From: Spike Curtis <spike@spikecurtis.com>
Date: Fri, 26 Jan 2024 10:47:26 +0400
Subject: [PATCH 07/10] feat: ignore context.Canceled by default in slogtest
 (#207)

* feat: ignore context.Canceled by default in slogtest

Signed-off-by: Spike Curtis <spike@coder.com>

* code review suggestions

Signed-off-by: Spike Curtis <spike@coder.com>

---------

Signed-off-by: Spike Curtis <spike@coder.com>
---
 sloggers/slogtest/t.go      | 39 +++++++++++++++++++++--
 sloggers/slogtest/t_test.go | 63 +++++++++++++++++++++++++++++++++++++
 2 files changed, 99 insertions(+), 3 deletions(-)

diff --git a/sloggers/slogtest/t.go b/sloggers/slogtest/t.go
index 6923054..df0225a 100644
--- a/sloggers/slogtest/t.go
+++ b/sloggers/slogtest/t.go
@@ -14,6 +14,8 @@ import (
 	"sync"
 	"testing"
 
+	"golang.org/x/xerrors"
+
 	"cdr.dev/slog"
 	"cdr.dev/slog/internal/entryhuman"
 	"cdr.dev/slog/sloggers/sloghuman"
@@ -36,13 +38,26 @@ type Options struct {
 	// conditions exist when t.Log is called concurrently of a test exiting. Set
 	// to true if you don't need this behavior.
 	SkipCleanup bool
+	// IgnoredErrorIs causes the test logger not to error the test on Error
+	// if the SinkEntry contains one of the listed errors in its "error" Field.
+	// Errors are matched using xerrors.Is().
+	//
+	// By default, context.Canceled and context.DeadlineExceeded are included,
+	// as these are nearly always benign in testing. Override to []error{} (zero
+	// length error slice) to disable the whitelist entirely.
+	IgnoredErrorIs []error
 }
 
-// Make creates a Logger that writes logs to tb in a human readable format.
+var DefaultIgnoredErrorIs = []error{context.Canceled, context.DeadlineExceeded}
+
+// Make creates a Logger that writes logs to tb in a human-readable format.
 func Make(tb testing.TB, opts *Options) slog.Logger {
 	if opts == nil {
 		opts = &Options{}
 	}
+	if opts.IgnoredErrorIs == nil {
+		opts.IgnoredErrorIs = DefaultIgnoredErrorIs
+	}
 
 	sink := &testSink{
 		tb:   tb,
@@ -66,7 +81,7 @@ type testSink struct {
 	testDone bool
 }
 
-func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
+func (ts *testSink) LogEntry(_ context.Context, ent slog.SinkEntry) {
 	ts.mu.RLock()
 	defer ts.mu.RUnlock()
 
@@ -83,7 +98,7 @@ func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
 	case slog.LevelDebug, slog.LevelInfo, slog.LevelWarn:
 		ts.tb.Log(sb.String())
 	case slog.LevelError, slog.LevelCritical:
-		if ts.opts.IgnoreErrors {
+		if ts.shouldIgnoreError(ent) {
 			ts.tb.Log(sb.String())
 		} else {
 			sb.WriteString(fmt.Sprintf(
@@ -98,6 +113,24 @@ func (ts *testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) {
 	}
 }
 
+func (ts *testSink) shouldIgnoreError(ent slog.SinkEntry) bool {
+	if ts.opts.IgnoreErrors {
+		return true
+	}
+	for _, f := range ent.Fields {
+		if f.Name == "error" {
+			if err, ok := f.Value.(error); ok {
+				for _, ig := range ts.opts.IgnoredErrorIs {
+					if xerrors.Is(err, ig) {
+						return true
+					}
+				}
+			}
+		}
+	}
+	return false
+}
+
 func (ts *testSink) Sync() {}
 
 var ctx = context.Background()
diff --git a/sloggers/slogtest/t_test.go b/sloggers/slogtest/t_test.go
index 9c1b88a..0637bc5 100644
--- a/sloggers/slogtest/t_test.go
+++ b/sloggers/slogtest/t_test.go
@@ -4,6 +4,9 @@ import (
 	"context"
 	"testing"
 
+	"golang.org/x/xerrors"
+
+	"cdr.dev/slog"
 	"cdr.dev/slog/internal/assert"
 	"cdr.dev/slog/sloggers/slogtest"
 )
@@ -15,6 +18,12 @@ func TestStateless(t *testing.T) {
 	slogtest.Debug(tb, "hello")
 	slogtest.Info(tb, "hello")
 
+	slogtest.Error(tb, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
+	slogtest.Error(tb, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
 	slogtest.Error(tb, "hello")
 	assert.Equal(t, "errors", 1, tb.errors)
 
@@ -45,6 +54,60 @@ func TestIgnoreErrors(t *testing.T) {
 	l.Fatal(bg, "hello")
 }
 
+func TestIgnoreErrorIs_Default(t *testing.T) {
+	t.Parallel()
+
+	tb := &fakeTB{}
+	l := slogtest.Make(tb, nil)
+
+	l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
+	l.Error(bg, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
+	l.Error(bg, "new", slog.Error(xerrors.New("test")))
+	assert.Equal(t, "errors", 1, tb.errors)
+
+	defer func() {
+		recover()
+		assert.Equal(t, "fatals", 1, tb.fatals)
+	}()
+
+	l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("fatal %w:", context.Canceled)))
+}
+
+func TestIgnoreErrorIs_Explicit(t *testing.T) {
+	t.Parallel()
+
+	tb := &fakeTB{}
+	ignored := xerrors.New("ignored")
+	notIgnored := xerrors.New("not ignored")
+	l := slogtest.Make(tb, &slogtest.Options{IgnoredErrorIs: []error{ignored}})
+
+	l.Error(bg, "ignored", slog.Error(xerrors.Errorf("test %w:", ignored)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
+	l.Error(bg, "not ignored", slog.Error(xerrors.Errorf("test %w:", notIgnored)))
+	assert.Equal(t, "errors", 1, tb.errors)
+
+	l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled)))
+	assert.Equal(t, "errors", 2, tb.errors)
+
+	l.Error(bg, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded)))
+	assert.Equal(t, "errors", 3, tb.errors)
+
+	l.Error(bg, "new", slog.Error(xerrors.New("test")))
+	assert.Equal(t, "errors", 4, tb.errors)
+
+	defer func() {
+		recover()
+		assert.Equal(t, "fatals", 1, tb.fatals)
+	}()
+
+	l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("test %w:", ignored)))
+}
+
 func TestCleanup(t *testing.T) {
 	t.Parallel()
 

From 3e5cea5e4d6a8c8396f626a8a4989cc02aea4ed1 Mon Sep 17 00:00:00 2001
From: Mathias Fredriksson <mafredri@gmail.com>
Date: Fri, 8 Nov 2024 08:14:28 +0200
Subject: [PATCH 08/10] ci: fix lint, fmt and upload artifacts (#218)

* chore(.github): update actions/upload-artifacts
---
 .github/workflows/ci.yml | 2 +-
 ci/fmt.mk                | 3 ++-
 ci/lint.mk               | 3 ++-
 3 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4a212ec..0b453e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
         env:
           COVERALLS_TOKEN: ${{ secrets.github_token }}
       - name: Upload coverage.html
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v4
         with:
           name: coverage
           path: ci/out/coverage.html
diff --git a/ci/fmt.mk b/ci/fmt.mk
index 7f74874..5519ed6 100644
--- a/ci/fmt.mk
+++ b/ci/fmt.mk
@@ -13,7 +13,8 @@ modtidy: gen
 	go mod tidy
 
 gofmt: gen
-	go run mvdan.cc/gofumpt@latest -w .
+	# gofumpt v0.7.0 requires Go 1.22 or later.
+	go run mvdan.cc/gofumpt@v0.6.0 -w .
 
 prettier:
 	npx prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml")
diff --git a/ci/lint.mk b/ci/lint.mk
index 36da85b..e190817 100644
--- a/ci/lint.mk
+++ b/ci/lint.mk
@@ -4,4 +4,5 @@ govet:
 	go vet ./...
 
 golint:
-	go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run .
+	# golangci-lint newer than v1.55.2 is not compatible with Go 1.20 when using go run.
+	go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 run .

From 0ec81e6e67bb1fe88d4d068a6c7325f449008201 Mon Sep 17 00:00:00 2001
From: Spike Curtis <spike@spikecurtis.com>
Date: Tue, 12 Nov 2024 08:18:20 +0400
Subject: [PATCH 09/10] feat: add IgnoreErrorFn to slogtest options (#217)

Signed-off-by: Spike Curtis <spike@coder.com>
---
 sloggers/slogtest/t.go      | 35 ++++++++++++++++++++------
 sloggers/slogtest/t_test.go | 49 +++++++++++++++++++++++++++++++++++++
 2 files changed, 76 insertions(+), 8 deletions(-)

diff --git a/sloggers/slogtest/t.go b/sloggers/slogtest/t.go
index df0225a..b1fbc86 100644
--- a/sloggers/slogtest/t.go
+++ b/sloggers/slogtest/t.go
@@ -46,6 +46,13 @@ type Options struct {
 	// as these are nearly always benign in testing. Override to []error{} (zero
 	// length error slice) to disable the whitelist entirely.
 	IgnoredErrorIs []error
+	// IgnoreErrorFn, if non-nil, defines a function that should return true if
+	// the given SinkEntry should not error the test on Error or Critical.  The
+	// result of this function is logically ORed with ignore directives defined
+	// by IgnoreErrors and IgnoredErrorIs. To depend exclusively on
+	// IgnoreErrorFn, set IgnoreErrors=false and IgnoredErrorIs=[]error{} (zero
+	// length error slice).
+	IgnoreErrorFn func(slog.SinkEntry) bool
 }
 
 var DefaultIgnoredErrorIs = []error{context.Canceled, context.DeadlineExceeded}
@@ -117,17 +124,16 @@ func (ts *testSink) shouldIgnoreError(ent slog.SinkEntry) bool {
 	if ts.opts.IgnoreErrors {
 		return true
 	}
-	for _, f := range ent.Fields {
-		if f.Name == "error" {
-			if err, ok := f.Value.(error); ok {
-				for _, ig := range ts.opts.IgnoredErrorIs {
-					if xerrors.Is(err, ig) {
-						return true
-					}
-				}
+	if err, ok := FindFirstError(ent); ok {
+		for _, ig := range ts.opts.IgnoredErrorIs {
+			if xerrors.Is(err, ig) {
+				return true
 			}
 		}
 	}
+	if ts.opts.IgnoreErrorFn != nil {
+		return ts.opts.IgnoreErrorFn(ent)
+	}
 	return false
 }
 
@@ -162,3 +168,16 @@ func Fatal(t testing.TB, msg string, fields ...any) {
 	slog.Helper()
 	l(t).Fatal(ctx, msg, fields...)
 }
+
+// FindFirstError finds the first slog.Field named "error" that contains an
+// error value.
+func FindFirstError(ent slog.SinkEntry) (err error, ok bool) {
+	for _, f := range ent.Fields {
+		if f.Name == "error" {
+			if err, ok = f.Value.(error); ok {
+				return err, true
+			}
+		}
+	}
+	return nil, false
+}
diff --git a/sloggers/slogtest/t_test.go b/sloggers/slogtest/t_test.go
index 0637bc5..2aa09d5 100644
--- a/sloggers/slogtest/t_test.go
+++ b/sloggers/slogtest/t_test.go
@@ -2,6 +2,7 @@ package slogtest_test
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	"golang.org/x/xerrors"
@@ -108,6 +109,46 @@ func TestIgnoreErrorIs_Explicit(t *testing.T) {
 	l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("test %w:", ignored)))
 }
 
+func TestIgnoreErrorFn(t *testing.T) {
+	t.Parallel()
+
+	tb := &fakeTB{}
+	ignored := testCodedError{code: 777}
+	notIgnored := testCodedError{code: 911}
+	l := slogtest.Make(tb, &slogtest.Options{IgnoreErrorFn: func(ent slog.SinkEntry) bool {
+		err, ok := slogtest.FindFirstError(ent)
+		if !ok {
+			t.Error("did not contain an error")
+			return false
+		}
+		ce := testCodedError{}
+		if !xerrors.As(err, &ce) {
+			return false
+		}
+		return ce.code != 911
+	}})
+
+	l.Error(bg, "ignored", slog.Error(xerrors.Errorf("test %w:", ignored)))
+	assert.Equal(t, "errors", 0, tb.errors)
+
+	l.Error(bg, "not ignored", slog.Error(xerrors.Errorf("test %w:", notIgnored)))
+	assert.Equal(t, "errors", 1, tb.errors)
+
+	// still ignored by default for IgnoredErrorIs
+	l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled)))
+	assert.Equal(t, "errors", 1, tb.errors)
+
+	l.Error(bg, "new", slog.Error(xerrors.New("test")))
+	assert.Equal(t, "errors", 2, tb.errors)
+
+	defer func() {
+		recover()
+		assert.Equal(t, "fatals", 1, tb.fatals)
+	}()
+
+	l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("test %w:", ignored)))
+}
+
 func TestCleanup(t *testing.T) {
 	t.Parallel()
 
@@ -163,3 +204,11 @@ func (tb *fakeTB) Fatal(v ...interface{}) {
 func (tb *fakeTB) Cleanup(fn func()) {
 	tb.cleanups = append(tb.cleanups, fn)
 }
+
+type testCodedError struct {
+	code int
+}
+
+func (e testCodedError) Error() string {
+	return fmt.Sprintf("code: %d", e.code)
+}

From 9df5e0a6c14572480711c6c06dd26ceabe32ad72 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 3 Jul 2025 17:42:22 +1000
Subject: [PATCH 10/10] fix!: handle `sql/driver.Valuer` types properly in
 `slogjson` (#219)

Currently, if a field like `sql.NullInt32` has `Valid: False`, `sloghuman` will export it's value as `<nil>`, regardless of it's `String`.
This is because it checks `(driver.Valuer).Value()`.

However, `slogjson` currently sets the value to the json string of the raw struct:
```json
{
  "fields": {
      "Code": "{Int32:0 Valid:false}",
      "ValidCode": "{Int32:12 Valid:true}"
  }
}
```

This PR handles this case by first checking if the type implements `sql/driver.Valuer`. If `Valid` is `false` then a JSON `null` value is produced:
```json
{
  "fields": {
      "Code": null,
      "ValidCode": 12
  }
}
```
This matches the behaviour of `sloghuman`.

This is technically a breaking change, as these types are now `T | null` instead of `String`, where `T` is the corresponding JSON type of `sql.Null<V>`
---
 map.go                             |  9 +++++++++
 map_test.go                        |  6 +++---
 sloggers/slogjson/slogjson_test.go | 29 ++++++++++++++++++++++++++++-
 3 files changed, 40 insertions(+), 4 deletions(-)

diff --git a/map.go b/map.go
index 636f4ee..cbf2991 100644
--- a/map.go
+++ b/map.go
@@ -2,6 +2,7 @@ package slog
 
 import (
 	"bytes"
+	"database/sql/driver"
 	"encoding/json"
 	"fmt"
 	"reflect"
@@ -70,6 +71,14 @@ func marshalList(rv reflect.Value) []byte {
 }
 
 func encode(v interface{}) []byte {
+	if vr, ok := v.(driver.Valuer); ok {
+		var err error
+		v, err = vr.Value()
+		if err != nil {
+			return encodeJSON(fmt.Sprintf("error calling Value: %v", err))
+		}
+	}
+
 	switch v := v.(type) {
 	case json.Marshaler:
 		return encodeJSON(v)
diff --git a/map_test.go b/map_test.go
index fce13b5..c89adf0 100644
--- a/map_test.go
+++ b/map_test.go
@@ -62,12 +62,12 @@ func TestMap(t *testing.T) {
 				{
 					"msg": "wrap1",
 					"fun": "cdr.dev/slog_test.TestMap.func2",
-					"loc": "`+mapTestFile+`:41" 
+					"loc": "`+mapTestFile+`:41"
 				},
 				{
 					"msg": "wrap2",
 					"fun": "cdr.dev/slog_test.TestMap.func2",
-					"loc": "`+mapTestFile+`:42" 
+					"loc": "`+mapTestFile+`:42"
 				},
 				"EOF"
 			],
@@ -93,7 +93,7 @@ func TestMap(t *testing.T) {
 					{
 						"msg": "failed to marshal to JSON",
 						"fun": "cdr.dev/slog.encodeJSON",
-						"loc": "`+mapTestFile+`:131"
+						"loc": "`+mapTestFile+`:140"
 					},
 					"json: error calling MarshalJSON for type slog_test.complexJSON: json: unsupported type: complex128"
 				],
diff --git a/sloggers/slogjson/slogjson_test.go b/sloggers/slogjson/slogjson_test.go
index 79a46e7..cdbffee 100644
--- a/sloggers/slogjson/slogjson_test.go
+++ b/sloggers/slogjson/slogjson_test.go
@@ -3,6 +3,7 @@ package slogjson_test
 import (
 	"bytes"
 	"context"
+	"database/sql"
 	"fmt"
 	"runtime"
 	"testing"
@@ -33,7 +34,33 @@ func TestMake(t *testing.T) {
 	l.Error(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou"))
 
 	j := entryjson.Filter(b.String(), "ts")
-	exp := fmt.Sprintf(`{"level":"ERROR","msg":"line1\n\nline2","caller":"%v:33","func":"cdr.dev/slog/sloggers/slogjson_test.TestMake","logger_names":["named"],"trace":"%v","span":"%v","fields":{"wowow":"me\nyou"}}
+	exp := fmt.Sprintf(`{"level":"ERROR","msg":"line1\n\nline2","caller":"%v:34","func":"cdr.dev/slog/sloggers/slogjson_test.TestMake","logger_names":["named"],"trace":"%v","span":"%v","fields":{"wowow":"me\nyou"}}
 `, slogjsonTestFile, span.SpanContext().TraceID().String(), span.SpanContext().SpanID().String())
 	assert.Equal(t, "entry", exp, j)
 }
+
+func TestNoDriverValue(t *testing.T) {
+	t.Parallel()
+
+	b := &bytes.Buffer{}
+	l := slog.Make(slogjson.Sink(b))
+	l = l.Named("named")
+	validField := sql.NullString{
+		String: "cat",
+		Valid:  true,
+	}
+	invalidField := sql.NullString{
+		String: "dog",
+		Valid:  false,
+	}
+	validInt := sql.NullInt64{
+		Int64: 42,
+		Valid: true,
+	}
+	l.Error(bg, "error!", slog.F("inval", invalidField), slog.F("val", validField), slog.F("int", validInt))
+
+	j := entryjson.Filter(b.String(), "ts")
+	exp := fmt.Sprintf(`{"level":"ERROR","msg":"error!","caller":"%v:60","func":"cdr.dev/slog/sloggers/slogjson_test.TestNoDriverValue","logger_names":["named"],"fields":{"inval":null,"val":"cat","int":42}}
+`, slogjsonTestFile)
+	assert.Equal(t, "entry", exp, j)
+}